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, ChunkLocator,
10 ChunkResponse, CollectionInfo, DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus,
11 DocumentResponse, EvalImportReport, EvalRunReport, FileEntry, GetChunkRequest, GetRequest,
12 InitialIndexingBlock, InitialIndexingOutcome, KboltError, LocalAction, LocalReport, Locator,
13 ModelInfo, MultiGetItem, MultiGetItemKind, MultiGetRequest, MultiGetResponse, OmitReason,
14 ReadLocator, RemoveScheduleRequest, RemoveScheduleSelector, ScheduleAddResponse,
15 ScheduleBackend, ScheduleInterval, ScheduleIntervalUnit, ScheduleRunResult, ScheduleScope,
16 ScheduleState, ScheduleStatusResponse, ScheduleTrigger, ScheduleWeekday, SearchEmptyReason,
17 SearchMode, SearchPipeline, SearchPipelineNotice, SearchPipelineStep,
18 SearchPipelineUnavailableReason, SearchRequest, StatusResponse, UpdateDecision,
19 UpdateDecisionKind, UpdateOptions, UpdateReport,
20};
21
22pub struct CliAdapter {
23 pub engine: Engine,
24 color: bool,
25}
26
27pub struct CliSearchOptions<'a> {
28 pub space: Option<&'a str>,
29 pub query: &'a str,
30 pub collections: &'a [String],
31 pub limit: usize,
32 pub min_score: f32,
33 pub deep: bool,
34 pub keyword: bool,
35 pub semantic: bool,
36 pub rerank: bool,
37 pub no_rerank: bool,
38 pub debug: bool,
39}
40
41pub struct CliGetOptions<'a> {
42 pub space: Option<&'a str>,
43 pub identifier: &'a str,
44 pub offset: Option<usize>,
45 pub limit: Option<usize>,
46}
47
48struct GroupedSearchResult<'a> {
49 primary: &'a kbolt_types::SearchResult,
50 additional_matches: Vec<&'a kbolt_types::SearchResult>,
51}
52
53const ADDITIONAL_MATCHES_SHOWN: usize = 3;
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum CliStyle {
57 Title,
58 Label,
59 Chrome,
60 Section,
61 Identity,
62 Action,
63 Warning,
64 Error,
65}
66
67impl CliStyle {
68 #[cfg(test)]
69 const ALL: [Self; 8] = [
70 Self::Title,
71 Self::Label,
72 Self::Chrome,
73 Self::Section,
74 Self::Identity,
75 Self::Action,
76 Self::Warning,
77 Self::Error,
78 ];
79}
80
81impl CliAdapter {
82 pub fn new(engine: Engine) -> Self {
83 Self {
84 engine,
85 color: false,
86 }
87 }
88
89 pub fn with_color(mut self, color: bool) -> Self {
90 self.color = color;
91 self
92 }
93
94 pub fn space_add(
95 &mut self,
96 name: &str,
97 description: Option<&str>,
98 strict: bool,
99 dirs: &[std::path::PathBuf],
100 ) -> Result<String> {
101 let color = self.color;
102 if strict {
103 use std::collections::HashSet;
104
105 let mut validation_errors = Vec::new();
106 let mut derived_names = HashSet::new();
107 for dir in dirs {
108 if !dir.is_absolute() || !dir.is_dir() {
109 validation_errors.push(format!("- {} -> invalid path", dir.display()));
110 continue;
111 }
112
113 let collection_name = dir.file_name().and_then(|item| item.to_str());
114 match collection_name {
115 Some(name) => {
116 if !derived_names.insert(name.to_string()) {
117 validation_errors.push(format!(
118 "- {} -> duplicate derived collection name '{name}'",
119 dir.display()
120 ));
121 }
122 }
123 None => validation_errors.push(format!(
124 "- {} -> cannot derive collection name from path",
125 dir.display()
126 )),
127 }
128 }
129
130 if !validation_errors.is_empty() {
131 let mut lines = Vec::new();
132 lines.push("strict mode aborted: one or more directories are invalid".to_string());
133 lines.extend(validation_errors);
134 return Err(kbolt_types::KboltError::InvalidInput(lines.join("\n")).into());
135 }
136 }
137
138 let added = self.engine.add_space(name, description)?;
139 if dirs.is_empty() {
140 let mut lines = vec![format_space_add_response(&added.name, color)];
141 if let Some(description) = added
142 .description
143 .as_deref()
144 .map(str::trim)
145 .filter(|description| !description.is_empty())
146 {
147 push_field_color(&mut lines, "description", description, color);
148 }
149 return Ok(lines.join("\n"));
150 }
151
152 let mut successes = Vec::new();
153 let mut failures = Vec::new();
154 for dir in dirs {
155 let collection_name = dir
156 .file_name()
157 .and_then(|item| item.to_str())
158 .map(ToString::to_string);
159
160 let result = self.engine.add_collection(AddCollectionRequest {
161 path: dir.clone(),
162 space: Some(name.to_string()),
163 name: collection_name,
164 description: None,
165 extensions: None,
166 no_index: true,
167 });
168
169 match result {
170 Ok(info) => successes.push(format!(
171 "{} -> {}/{}",
172 dir.display(),
173 info.collection.space,
174 info.collection.name
175 )),
176 Err(err) => {
177 if strict {
178 let rollback_result = self.engine.remove_space(name);
179 return match rollback_result {
180 Ok(()) => Err(err),
181 Err(rollback_err) => Err(kbolt_types::KboltError::Internal(format!(
182 "strict mode rollback failed: add error: {err}; rollback error: {rollback_err}"
183 ))
184 .into()),
185 };
186 }
187 failures.push(format!("{} -> {}", dir.display(), err));
188 }
189 }
190 }
191
192 let mut lines = Vec::new();
193 lines.push(format_space_add_response(&added.name, color));
194 if let Some(description) = added
195 .description
196 .as_deref()
197 .map(str::trim)
198 .filter(|description| !description.is_empty())
199 {
200 push_field_color(&mut lines, "description", description, color);
201 }
202 push_section_color(&mut lines, "collections", color);
203 push_bullet_color(&mut lines, format!("{} registered", successes.len()), color);
204 for success in successes {
205 push_bullet_color(&mut lines, success, color);
206 }
207 if !failures.is_empty() {
208 push_section_color(&mut lines, "failed", color);
209 push_bullet_color(
210 &mut lines,
211 format!("{} collection(s)", failures.len()),
212 color,
213 );
214 for failure in failures {
215 push_bullet_color(&mut lines, failure, color);
216 }
217 }
218 push_section_color(&mut lines, "indexing", color);
219 push_warning_bullet_color(&mut lines, "skipped (collections registered only)", color);
220 push_section_color(&mut lines, "next", color);
221 push_action_bullet_color(
222 &mut lines,
223 format!("kbolt --space {} update", shell_quote_arg(&added.name)),
224 color,
225 );
226
227 Ok(lines.join("\n"))
228 }
229
230 pub fn space_describe(&self, name: &str, text: &str) -> Result<String> {
231 self.engine.describe_space(name, text)?;
232 Ok(format_space_describe_response(name, self.color))
233 }
234
235 pub fn space_rename(&mut self, old: &str, new: &str) -> Result<String> {
236 self.engine.rename_space(old, new)?;
237 Ok(format_space_rename_response(old, new, self.color))
238 }
239
240 pub fn space_remove(&mut self, name: &str) -> Result<String> {
241 self.engine.remove_space(name)?;
242 if name == "default" {
243 return Ok(format_space_remove_default_response(self.color));
244 }
245 Ok(format_space_remove_response(name, self.color))
246 }
247
248 pub fn space_default(&mut self, name: Option<&str>) -> Result<String> {
249 if let Some(space_name) = name {
250 let updated = self.engine.set_default_space(Some(space_name))?;
251 let value = updated.unwrap_or_default();
252 return Ok(format_space_default_response(Some(&value), self.color));
253 }
254
255 let current = self.engine.config().default_space.as_deref();
256 Ok(format_space_default_response(current, self.color))
257 }
258
259 pub fn space_current(&self, explicit: Option<&str>) -> Result<String> {
260 let active = self.engine.current_space(explicit)?;
261 let output = match active {
262 Some(active) => {
263 let source = match active.source {
264 ActiveSpaceSource::Flag => "flag",
265 ActiveSpaceSource::EnvVar => "env",
266 ActiveSpaceSource::ConfigDefault => "default",
267 };
268 format_space_current_response(Some((&active.name, source)), self.color)
269 }
270 None => format_space_current_response(None, self.color),
271 };
272 Ok(output)
273 }
274
275 pub fn space_list(&self) -> Result<String> {
276 let spaces = self.engine.list_spaces()?;
277 let mut lines = Vec::with_capacity(spaces.len() + 1);
278 push_section_color(&mut lines, "spaces", self.color);
279 if spaces.is_empty() {
280 push_bullet_color(&mut lines, "none", self.color);
281 return Ok(lines.join("\n"));
282 }
283
284 for space in spaces {
285 let description = space.description.unwrap_or_default();
286 push_bullet_color(
287 &mut lines,
288 format!(
289 "{}: {} collection(s), {} document(s), {} chunk(s)",
290 space.name, space.collection_count, space.document_count, space.chunk_count
291 ),
292 self.color,
293 );
294 if !description.is_empty() {
295 push_field_color(&mut lines, "description", description, self.color);
296 }
297 }
298 Ok(lines.join("\n"))
299 }
300
301 pub fn space_info(&self, name: &str) -> Result<String> {
302 let space = self.engine.space_info(name)?;
303 let mut lines = vec![format!("space: {}", space.name)];
304 if let Some(description) = space
305 .description
306 .as_deref()
307 .map(str::trim)
308 .filter(|description| !description.is_empty())
309 {
310 push_field_color(&mut lines, "description", description, self.color);
311 }
312 push_section_color(&mut lines, "contents", self.color);
313 push_bullet_color(
314 &mut lines,
315 format!("{} collection(s)", space.collection_count),
316 self.color,
317 );
318 push_bullet_color(
319 &mut lines,
320 format!("{} document(s)", space.document_count),
321 self.color,
322 );
323 push_bullet_color(
324 &mut lines,
325 format!("{} chunk(s)", space.chunk_count),
326 self.color,
327 );
328 push_section_color(&mut lines, "timestamps", self.color);
329 push_bullet_color(&mut lines, format!("created {}", space.created), self.color);
330 Ok(lines.join("\n"))
331 }
332
333 pub fn collection_list(&self, space: Option<&str>) -> Result<String> {
334 let collections = self.engine.list_collections(space)?;
335 let mut lines = Vec::with_capacity(collections.len() + 1);
336 push_section_color(&mut lines, "collections", self.color);
337 if collections.is_empty() {
338 push_bullet_color(&mut lines, "none", self.color);
339 return Ok(lines.join("\n"));
340 }
341
342 for collection in collections {
343 push_bullet_color(
344 &mut lines,
345 format!(
346 "{}/{} ({})",
347 collection.space,
348 collection.name,
349 collection.path.display()
350 ),
351 self.color,
352 );
353 }
354 Ok(lines.join("\n"))
355 }
356
357 pub fn collection_add(
358 &self,
359 space: Option<&str>,
360 path: &std::path::Path,
361 name: Option<&str>,
362 description: Option<&str>,
363 extensions: Option<&[String]>,
364 no_index: bool,
365 ) -> Result<String> {
366 let added = self.engine.add_collection(AddCollectionRequest {
367 path: path.to_path_buf(),
368 space: space.map(ToString::to_string),
369 name: name.map(ToString::to_string),
370 description: description.map(ToString::to_string),
371 extensions: extensions.map(|items| items.to_vec()),
372 no_index,
373 })?;
374
375 Ok(format_collection_add_result_color(&added, self.color))
376 }
377
378 pub fn collection_info(&self, space: Option<&str>, name: &str) -> Result<String> {
379 let collection = self.engine.collection_info(space, name)?;
380
381 Ok(format_collection_info_color(&collection, self.color))
382 }
383
384 pub fn collection_describe(
385 &self,
386 space: Option<&str>,
387 name: &str,
388 text: &str,
389 ) -> Result<String> {
390 self.engine.describe_collection(space, name, text)?;
391 Ok(format_collection_describe_response(name, self.color))
392 }
393
394 pub fn collection_rename(&self, space: Option<&str>, old: &str, new: &str) -> Result<String> {
395 self.engine.rename_collection(space, old, new)?;
396 Ok(format_collection_rename_response(old, new, self.color))
397 }
398
399 pub fn collection_remove(&self, space: Option<&str>, name: &str) -> Result<String> {
400 let collection = self.engine.collection_info(space, name)?;
401 self.engine.remove_collection(space, name)?;
402 Ok(format_collection_remove_response(
403 &collection.space,
404 name,
405 self.color,
406 ))
407 }
408
409 pub fn ignore_show(&self, space: Option<&str>, collection: &str) -> Result<String> {
410 let (resolved_space, content) = self.engine.read_collection_ignore(space, collection)?;
411 let mut lines = vec![format!("ignore: {resolved_space}/{collection}")];
412 push_section_color(&mut lines, "contents", self.color);
413 if let Some(content) = content {
414 append_ignore_content_lines(&mut lines, &content);
415 } else {
416 push_bullet_color(&mut lines, "none", self.color);
417 }
418 Ok(lines.join("\n"))
419 }
420
421 pub fn ignore_add(
422 &self,
423 space: Option<&str>,
424 collection: &str,
425 pattern: &str,
426 ) -> Result<String> {
427 let (resolved_space, normalized_pattern) = self
428 .engine
429 .add_collection_ignore_pattern(space, collection, pattern)?;
430 let mut lines = vec![format!("ignore updated: {resolved_space}/{collection}")];
431 push_section_color(&mut lines, "added", self.color);
432 push_bullet_color(&mut lines, normalized_pattern, self.color);
433 Ok(lines.join("\n"))
434 }
435
436 pub fn ignore_remove(
437 &self,
438 space: Option<&str>,
439 collection: &str,
440 pattern: &str,
441 ) -> Result<String> {
442 let (resolved_space, removed_count) = self
443 .engine
444 .remove_collection_ignore_pattern(space, collection, pattern)?;
445 if removed_count == 0 {
446 let mut lines = vec![format!("ignore unchanged: {resolved_space}/{collection}")];
447 push_section_color(&mut lines, "missing", self.color);
448 push_bullet_color(&mut lines, pattern, self.color);
449 return Ok(lines.join("\n"));
450 }
451
452 let mut lines = vec![format!("ignore updated: {resolved_space}/{collection}")];
453 push_section_color(&mut lines, "removed", self.color);
454 push_bullet_color(&mut lines, pattern, self.color);
455 push_section_color(&mut lines, "matches", self.color);
456 push_bullet_color(&mut lines, removed_count, self.color);
457 Ok(lines.join("\n"))
458 }
459
460 pub fn ignore_list(&self, space: Option<&str>) -> Result<String> {
461 let entries = self.engine.list_collection_ignores(space)?;
462 let mut lines = Vec::new();
463 push_section_color(&mut lines, "ignore files", self.color);
464 if entries.is_empty() {
465 push_bullet_color(&mut lines, "none", self.color);
466 return Ok(lines.join("\n"));
467 }
468
469 for entry in entries {
470 push_bullet_color(
471 &mut lines,
472 format!(
473 "{}/{}: {} pattern(s)",
474 entry.space, entry.collection, entry.pattern_count
475 ),
476 self.color,
477 );
478 }
479
480 Ok(lines.join("\n"))
481 }
482
483 pub fn ignore_edit(&self, space: Option<&str>, collection: &str) -> Result<String> {
484 let (resolved_space, path) = self
485 .engine
486 .prepare_collection_ignore_edit(space, collection)?;
487 let editor_command = resolve_editor_command()?;
488
489 let mut process = std::process::Command::new(&editor_command[0]);
490 if editor_command.len() > 1 {
491 process.args(&editor_command[1..]);
492 }
493 process.arg(&path);
494
495 let status = process.status().map_err(|err| {
496 KboltError::Internal(format!(
497 "failed to launch editor '{}': {err}",
498 editor_command[0]
499 ))
500 })?;
501 if !status.success() {
502 return Err(
503 KboltError::Internal(format!("editor exited with status: {status}")).into(),
504 );
505 }
506
507 let mut lines = vec![format!("ignore updated: {resolved_space}/{collection}")];
508 push_field_color(&mut lines, "path", path.display(), self.color);
509 Ok(lines.join("\n"))
510 }
511
512 pub fn models_list(&self) -> Result<String> {
513 let status = self.engine.model_status()?;
514 Ok(format_models_list_color(&status, self.color))
515 }
516
517 pub fn eval_run(&self, eval_file: Option<&Path>) -> Result<String> {
518 let report = self.engine.run_eval(eval_file)?;
519 Ok(format_eval_run_report_color(&report, self.color))
520 }
521
522 pub fn search(&self, options: CliSearchOptions<'_>) -> Result<String> {
523 let CliSearchOptions {
524 space,
525 query,
526 collections,
527 limit,
528 min_score,
529 deep,
530 keyword,
531 semantic,
532 rerank,
533 no_rerank,
534 debug,
535 } = options;
536 let mode_flags = deep as u8 + keyword as u8 + semantic as u8;
537 if mode_flags > 1 {
538 return Err(KboltError::InvalidInput(
539 "only one of --deep, --keyword, or --semantic can be set".to_string(),
540 )
541 .into());
542 }
543
544 let mode = if deep {
545 SearchMode::Deep
546 } else if keyword {
547 SearchMode::Keyword
548 } else if semantic {
549 SearchMode::Semantic
550 } else {
551 SearchMode::Auto
552 };
553 let effective_no_rerank = resolve_no_rerank_for_mode(mode.clone(), rerank, no_rerank);
554 let engine_limit = if debug {
555 limit
556 } else {
557 limit.saturating_mul(2)
558 };
559
560 let response = self.engine.search(SearchRequest {
561 query: query.to_string(),
562 mode,
563 space: space.map(ToString::to_string),
564 collections: collections.to_vec(),
565 limit: engine_limit,
566 min_score,
567 no_rerank: effective_no_rerank,
568 debug,
569 })?;
570
571 let color = self.color && !debug;
572 let mut lines = Vec::new();
573
574 if debug {
575 push_section(&mut lines, "debug");
576 push_field(&mut lines, "query", &response.query);
577 push_field(
578 &mut lines,
579 "mode",
580 format!(
581 "{} -> {}",
582 format_search_mode(&response.requested_mode),
583 format_search_mode(&response.effective_mode)
584 ),
585 );
586 push_field(
587 &mut lines,
588 "pipeline",
589 format_search_pipeline(&response.pipeline),
590 );
591 push_field(&mut lines, "result count", response.results.len());
592 for notice in &response.pipeline.notices {
593 push_field(&mut lines, "note", format_search_pipeline_notice(notice));
594 }
595 }
596
597 if debug {
598 push_section(&mut lines, "results");
599 if response.results.is_empty() {
600 push_bullet(&mut lines, "none");
601 }
602
603 for item in &response.results {
604 push_bullet(&mut lines, &item.docid);
605 push_field(&mut lines, "score", format!("{:.3}", item.score));
606 push_field(
607 &mut lines,
608 "path",
609 format_search_result_path(&item.space, &item.path),
610 );
611 push_field(&mut lines, "chunk", shell_quote_arg(&item.chunk.locator));
612 if let Some(heading) = &item.heading {
613 push_field(&mut lines, "heading", heading);
614 }
615 lines.push(" snippet:".to_string());
616 let snippet = truncate_snippet(&item.text, 4);
617 for snippet_line in snippet.lines() {
618 lines.push(format!(" {snippet_line}"));
619 }
620 if let Some(signals) = &item.signals {
621 lines.push(" signals:".to_string());
622 lines.push(format!(
623 " bm25: {}",
624 format_optional_search_signal(signals.bm25)
625 ));
626 lines.push(format!(
627 " dense: {}",
628 format_optional_search_signal(signals.dense)
629 ));
630 lines.push(format!(
631 " fusion: {}",
632 format_search_signal(signals.fusion)
633 ));
634 lines.push(format!(
635 " reranker: {}",
636 format_optional_search_signal(signals.reranker)
637 ));
638 }
639 }
640 } else {
641 let grouped_results = group_search_results(&response.results, limit);
642 lines.push(paint_title(
643 color,
644 format!(
645 "{} document{}",
646 grouped_results.len(),
647 if grouped_results.len() == 1 { "" } else { "s" }
648 ),
649 ));
650 lines.push(format_field(color, "query", &response.query));
651 lines.push(format_field(
652 color,
653 "mode",
654 format_search_mode(&response.effective_mode),
655 ));
656
657 for (index, item) in grouped_results.iter().enumerate() {
658 let locator = self.search_chunk_locator(item.primary)?;
659 let read_command = format_chunk_read_command(&item.primary.space, &locator);
660 lines.push(String::new());
661 lines.push(format!(
662 "{}. {} {}",
663 index + 1,
664 paint_title(color, &item.primary.title),
665 paint_identity(color, &locator)
666 ));
667 lines.push(format!(
668 " {} {}",
669 paint_label(color, "path:"),
670 paint_identity(
671 color,
672 format_search_result_path(&item.primary.space, &item.primary.path),
673 )
674 ));
675 if let Some(heading) = &item.primary.heading {
676 lines.push(format!(" {} {heading}", paint_label(color, "section:")));
677 }
678 lines.push(format!(
679 " {} {}",
680 paint_label(color, "read:"),
681 paint_action(color, read_command)
682 ));
683 lines.push(String::new());
684 let snippet = truncate_snippet(&item.primary.text, 4);
685 for snippet_line in snippet.lines() {
686 lines.push(format!(" {snippet_line}"));
687 }
688 if !item.additional_matches.is_empty() {
689 lines.push(String::new());
690 lines.push(format!(
691 " {} {} more chunk{}",
692 paint_label(color, "also found:"),
693 item.additional_matches.len(),
694 if item.additional_matches.len() == 1 {
695 ""
696 } else {
697 "s"
698 }
699 ));
700 for matched in item
701 .additional_matches
702 .iter()
703 .take(ADDITIONAL_MATCHES_SHOWN)
704 {
705 lines.push(format!(
706 " {} {}",
707 paint_chrome(color, "-"),
708 format_additional_match_label(matched)
709 ));
710 }
711 let hidden = item
712 .additional_matches
713 .len()
714 .saturating_sub(ADDITIONAL_MATCHES_SHOWN);
715 if hidden > 0 {
716 lines.push(format!(
717 " {} +{} more chunk{}",
718 paint_chrome(color, "-"),
719 hidden,
720 if hidden == 1 { "" } else { "s" }
721 ));
722 }
723 }
724 }
725 }
726
727 if response.results.is_empty() {
728 if let Some(hint) =
729 self.empty_index_hint(space, collections, response.empty_reason.as_ref())
730 {
731 lines.push(String::new());
732 lines.push(hint);
733 }
734 }
735
736 if let Some(hint) = response.staleness_hint {
737 lines.push(String::new());
738 if !debug {
739 for notice in &response.pipeline.notices {
740 lines.push(format_normal_search_pipeline_notice_color(
741 notice,
742 &response.effective_mode,
743 color,
744 ));
745 }
746 }
747 lines.push(hint);
748 }
749
750 if debug {
751 push_section(&mut lines, "elapsed");
752 push_bullet(&mut lines, format!("{}ms", response.elapsed_ms));
753 }
754
755 Ok(lines.join("\n"))
756 }
757
758 fn search_chunk_locator(&self, result: &kbolt_types::SearchResult) -> Result<String> {
759 self.compact_chunk_locator(
760 &result.chunk.locator,
761 &result.space,
762 &result.docid,
763 result.chunk.ordinal,
764 )
765 }
766
767 fn chunk_response_locator(&self, chunk: &ChunkResponse) -> Result<String> {
768 self.compact_chunk_locator(
769 &chunk.locator,
770 &chunk.space,
771 &chunk.docid,
772 chunk.chunk_ordinal,
773 )
774 }
775
776 fn compact_chunk_locator(
777 &self,
778 full_locator: &str,
779 space: &str,
780 fallback_docid: &str,
781 chunk_ordinal: usize,
782 ) -> Result<String> {
783 let Some(full_docid) = full_docid_from_chunk_locator(full_locator) else {
784 return Ok(format_compact_chunk_locator(fallback_docid, chunk_ordinal));
785 };
786 let min_len = fallback_docid.trim_start_matches('#').len();
787 let docid = self
788 .engine
789 .shortest_unambiguous_docid_prefix(full_docid, space, min_len)?;
790 Ok(format_compact_chunk_locator(&docid, chunk_ordinal))
791 }
792
793 fn empty_index_hint(
794 &self,
795 space: Option<&str>,
796 requested: &[String],
797 empty_reason: Option<&SearchEmptyReason>,
798 ) -> Option<String> {
799 let all = self.engine.list_collections(space).ok()?;
800 let scoped: Vec<_> = if requested.is_empty() {
801 all
802 } else {
803 all.into_iter()
804 .filter(|c| requested.iter().any(|r| r == &c.name))
805 .collect()
806 };
807
808 if scoped.is_empty() {
811 let space_arg = shell_quote_arg(space.unwrap_or("default"));
812 let mut lines = vec!["no collections registered yet".to_string()];
813 push_section_color(&mut lines, "next", self.color);
814 push_action_bullet_color(
815 &mut lines,
816 format!("kbolt --space {space_arg} collection add /path/to/docs"),
817 self.color,
818 );
819 return Some(lines.join("\n"));
820 }
821
822 if matches!(empty_reason, Some(SearchEmptyReason::UnindexedScope)) {
825 let mut lines = vec!["nothing indexed for this scope".to_string()];
826 push_section_color(&mut lines, "next", self.color);
827 for command in empty_scope_update_commands(space, requested, &scoped) {
828 push_action_bullet_color(&mut lines, command, self.color);
829 }
830 return Some(lines.join("\n"));
831 }
832
833 None
834 }
835
836 pub fn update(
837 &self,
838 space: Option<&str>,
839 collections: &[String],
840 no_embed: bool,
841 dry_run: bool,
842 verbose: bool,
843 ) -> Result<String> {
844 let report = self.engine.update(UpdateOptions {
845 space: space.map(ToString::to_string),
846 collections: collections.to_vec(),
847 no_embed,
848 dry_run,
849 verbose,
850 })?;
851
852 Ok(format_update_report_color(
853 &report, verbose, no_embed, self.color,
854 ))
855 }
856
857 pub fn schedule_add(&self, req: AddScheduleRequest) -> Result<String> {
858 let response = self.engine.add_schedule(req)?;
859 Ok(format_schedule_add_response_color(&response, self.color))
860 }
861
862 pub fn schedule_status(&self) -> Result<String> {
863 let response = self.engine.schedule_status()?;
864 Ok(format_schedule_status_response_color(&response, self.color))
865 }
866
867 pub fn schedule_remove(&self, req: RemoveScheduleRequest) -> Result<String> {
868 let selector = req.selector.clone();
869 let response = self.engine.remove_schedule(req)?;
870 Ok(format_schedule_remove_response_color(
871 &selector, &response, self.color,
872 ))
873 }
874
875 pub fn status(&self, space: Option<&str>) -> Result<String> {
876 let status = self.engine.status(space)?;
877 let active_space = active_space_name_for_status(&self.engine, space);
878 Ok(format_status_response_color(
879 &status,
880 active_space.as_deref(),
881 self.color,
882 ))
883 }
884
885 pub fn ls(
886 &self,
887 space: Option<&str>,
888 collection: &str,
889 prefix: Option<&str>,
890 all: bool,
891 ) -> Result<String> {
892 let mut files = self.engine.list_files(space, collection, prefix)?;
893 if !all {
894 files.retain(|file| file.active);
895 }
896
897 Ok(format_file_list_color(&files, all, self.color))
898 }
899
900 pub fn get(&self, options: CliGetOptions<'_>) -> Result<String> {
901 let CliGetOptions {
902 space,
903 identifier,
904 offset,
905 limit,
906 } = options;
907 let color = self.color;
908 if let Some(locator) = ChunkLocator::parse(identifier)? {
909 let chunk = self.engine.get_chunk(GetChunkRequest {
910 locator,
911 space: space.map(ToString::to_string),
912 offset,
913 limit,
914 })?;
915 let display_locator = self.chunk_response_locator(&chunk)?;
916 return Ok(format_chunk_response(&chunk, &display_locator, color));
917 }
918
919 let document = self.engine.get_document(GetRequest {
920 locator: Locator::parse(identifier),
921 space: space.map(ToString::to_string),
922 offset,
923 limit,
924 })?;
925
926 Ok(format_document_response(&document, color))
927 }
928
929 pub fn multi_get(
930 &self,
931 space: Option<&str>,
932 locators: &[String],
933 max_files: usize,
934 max_bytes: usize,
935 ) -> Result<String> {
936 let locators = locators
937 .iter()
938 .map(|item| ReadLocator::parse(item))
939 .collect::<kbolt_types::Result<Vec<_>>>()?;
940
941 let response = self.engine.multi_get(MultiGetRequest {
942 locators,
943 space: space.map(ToString::to_string),
944 max_files,
945 max_bytes,
946 })?;
947 let chunk_display_locators = self.multi_get_chunk_display_locators(&response)?;
948
949 Ok(format_multi_get_response_color_with_chunk_locators(
950 &response,
951 self.color,
952 &chunk_display_locators,
953 ))
954 }
955
956 fn multi_get_chunk_display_locators(
957 &self,
958 response: &MultiGetResponse,
959 ) -> Result<HashMap<String, String>> {
960 let mut locators = HashMap::new();
961 for item in &response.items {
962 let MultiGetItem::Chunk(chunk) = item else {
963 continue;
964 };
965 locators.insert(chunk.locator.clone(), self.chunk_response_locator(chunk)?);
966 }
967 for omitted in &response.omitted {
968 if omitted.kind != MultiGetItemKind::Chunk {
969 continue;
970 }
971 let Some(locator) = ChunkLocator::parse(&omitted.locator)? else {
972 continue;
973 };
974 locators.insert(
975 omitted.locator.clone(),
976 self.compact_chunk_locator(
977 &omitted.locator,
978 &omitted.space,
979 &omitted.docid,
980 locator.chunk_ordinal,
981 )?,
982 );
983 }
984 Ok(locators)
985 }
986}
987
988fn empty_scope_update_commands(
989 space: Option<&str>,
990 requested: &[String],
991 scoped: &[CollectionInfo],
992) -> Vec<String> {
993 if requested.is_empty() {
994 return match space {
995 Some(space) => vec![format!("kbolt --space {} update", shell_quote_arg(space))],
996 None => vec!["kbolt update".to_string()],
997 };
998 }
999
1000 let mut grouped: Vec<(&str, Vec<&str>)> = Vec::new();
1001 for collection in scoped {
1002 if let Some((_, names)) = grouped
1003 .iter_mut()
1004 .find(|(space_name, _)| *space_name == collection.space.as_str())
1005 {
1006 names.push(collection.name.as_str());
1007 } else {
1008 grouped.push((collection.space.as_str(), vec![collection.name.as_str()]));
1009 }
1010 }
1011
1012 grouped
1013 .into_iter()
1014 .map(|(space_name, collections)| {
1015 let mut command = format!("kbolt --space {} update", shell_quote_arg(space_name));
1016 for collection in collections {
1017 command.push_str(" --collection ");
1018 command.push_str(&shell_quote_arg(collection));
1019 }
1020 command
1021 })
1022 .collect()
1023}
1024
1025pub fn format_doctor_report(report: &DoctorReport) -> String {
1026 format_doctor_report_color(report, false)
1027}
1028
1029pub fn format_doctor_report_color(report: &DoctorReport, color: bool) -> String {
1030 let mut lines = Vec::new();
1031
1032 let failures: Vec<_> = report
1033 .checks
1034 .iter()
1035 .filter(|c| c.status == DoctorCheckStatus::Fail)
1036 .collect();
1037 let warnings: Vec<_> = report
1038 .checks
1039 .iter()
1040 .filter(|c| c.status == DoctorCheckStatus::Warn)
1041 .collect();
1042 let expected_unindexed_warnings: Vec<_> = warnings
1043 .iter()
1044 .filter(|check| is_expected_unindexed_storage_warning(check))
1045 .collect();
1046
1047 match report.setup_status {
1048 DoctorSetupStatus::ConfigMissing => {
1049 lines.push("kbolt is not set up".to_string());
1050 push_section_color(&mut lines, "next", color);
1051 push_action_bullet_color(&mut lines, "kbolt setup local", color);
1052 return lines.join("\n");
1053 }
1054 DoctorSetupStatus::ConfigInvalid => {
1055 lines.push("kbolt configuration is invalid".to_string());
1056 push_section_color(&mut lines, "failures", color);
1057 for check in &failures {
1058 push_error_bullet_color(&mut lines, &check.message, color);
1059 if let Some(fix) = check.fix.as_deref() {
1060 push_action_field_color(&mut lines, "fix", fix, color);
1061 }
1062 }
1063 if let Some(path) = report.config_file.as_ref() {
1064 push_section_color(&mut lines, "config", color);
1065 push_identity_bullet_color(&mut lines, path.display(), color);
1066 }
1067 return lines.join("\n");
1068 }
1069 DoctorSetupStatus::NotConfigured => {
1070 lines.push("kbolt is installed but no inference roles are configured".to_string());
1071 push_section_color(&mut lines, "next", color);
1072 push_action_bullet_color(&mut lines, "kbolt setup local", color);
1073 return lines.join("\n");
1074 }
1075 DoctorSetupStatus::Configured => {}
1076 }
1077
1078 if report.ready && failures.is_empty() {
1079 lines.push("kbolt is ready".to_string());
1080 } else {
1081 lines.push("kbolt has issues".to_string());
1082 }
1083
1084 let configured_roles: Vec<_> = report
1085 .checks
1086 .iter()
1087 .filter(|c| c.id.ends_with(".bound") && c.status == DoctorCheckStatus::Pass)
1088 .collect();
1089 if !configured_roles.is_empty() {
1090 push_section_color(&mut lines, "configured", color);
1091 for check in &configured_roles {
1092 let role = check.scope.strip_prefix("roles.").unwrap_or(&check.scope);
1093 push_bullet_color(&mut lines, role, color);
1094 }
1095 }
1096
1097 let not_enabled: Vec<_> = report
1098 .checks
1099 .iter()
1100 .filter(|c| c.id.ends_with(".bound") && c.status == DoctorCheckStatus::Warn)
1101 .collect();
1102 if !not_enabled.is_empty() {
1103 push_section_color(&mut lines, "not enabled", color);
1104 for check in ¬_enabled {
1105 let role = check.scope.strip_prefix("roles.").unwrap_or(&check.scope);
1106 push_warning_bullet_color(&mut lines, role, color);
1107 }
1108 }
1109
1110 if !failures.is_empty() {
1111 push_section_color(&mut lines, "failures", color);
1112 for check in &failures {
1113 push_error_bullet_color(
1114 &mut lines,
1115 format!("{}: {}", check.id, check.message),
1116 color,
1117 );
1118 if let Some(fix) = check.fix.as_deref() {
1119 push_action_field_color(&mut lines, "fix", fix, color);
1120 }
1121 }
1122 }
1123
1124 if !warnings.is_empty() && failures.is_empty() {
1125 let other_warnings: Vec<_> = warnings
1127 .iter()
1128 .filter(|c| {
1129 !c.id.ends_with(".bound")
1130 && !c.id.ends_with(".reachable")
1131 && !is_expected_unindexed_storage_warning(c)
1132 })
1133 .collect();
1134 if !other_warnings.is_empty() {
1135 push_section_color(&mut lines, "warnings", color);
1136 for check in &other_warnings {
1137 push_warning_bullet_color(
1138 &mut lines,
1139 format!("{}: {}", check.id, check.message),
1140 color,
1141 );
1142 if let Some(fix) = check.fix.as_deref() {
1143 push_action_field_color(&mut lines, "fix", fix, color);
1144 }
1145 }
1146 }
1147 }
1148
1149 if !expected_unindexed_warnings.is_empty() && failures.is_empty() {
1150 push_section_color(&mut lines, "indexing", color);
1151 push_warning_bullet_color(&mut lines, "no collections have been indexed yet", color);
1152 push_section_color(&mut lines, "next", color);
1153 push_action_bullet_color(&mut lines, "kbolt collection add /path/to/docs", color);
1154 push_action_hint_color(
1155 &mut lines,
1156 "or, if collections are already registered",
1157 "kbolt update",
1158 color,
1159 );
1160 }
1161
1162 lines.join("\n")
1163}
1164
1165fn is_expected_unindexed_storage_warning(check: &DoctorCheck) -> bool {
1166 check.status == DoctorCheckStatus::Warn
1167 && matches!(
1168 check.id.as_str(),
1169 "storage.sqlite_readable" | "storage.search_indexes_readable"
1170 )
1171}
1172
1173pub fn format_local_report(report: &LocalReport) -> String {
1174 format_local_report_color(report, false)
1175}
1176
1177pub fn format_local_report_color(report: &LocalReport, color: bool) -> String {
1178 let mut lines = Vec::new();
1179
1180 let action_label = match report.action {
1181 LocalAction::Setup => "local setup complete",
1182 LocalAction::Start => "local servers started",
1183 LocalAction::Stop => "local servers stopped",
1184 LocalAction::Status => "local servers",
1185 LocalAction::EnableDeep => "deep search enabled",
1186 };
1187
1188 if report.action == LocalAction::Stop || report.ready {
1189 lines.push(action_label.to_string());
1190 } else {
1191 lines.push(format!("{action_label} (not ready)"));
1192 }
1193
1194 if !report.notes.is_empty() {
1195 push_section_color(&mut lines, "notes", color);
1196 for note in &report.notes {
1197 push_bullet_color(&mut lines, note, color);
1198 }
1199 }
1200
1201 let ready_services: Vec<_> = if report.action == LocalAction::Stop {
1202 Vec::new()
1203 } else {
1204 report.services.iter().filter(|s| s.ready).collect()
1205 };
1206 let issue_services: Vec<_> = if report.action == LocalAction::Stop {
1207 report
1208 .services
1209 .iter()
1210 .filter(|s| s.configured && (s.running || s.ready))
1211 .collect()
1212 } else {
1213 report
1214 .services
1215 .iter()
1216 .filter(|s| s.configured && !s.ready)
1217 .collect()
1218 };
1219 let unconfigured_services: Vec<_> = report.services.iter().filter(|s| !s.configured).collect();
1220
1221 if !ready_services.is_empty() {
1222 push_section_color(&mut lines, "ready", color);
1223 for service in &ready_services {
1224 push_bullet_color(
1225 &mut lines,
1226 format!("{} ({})", service.name, service.model),
1227 color,
1228 );
1229 }
1230 }
1231
1232 if !issue_services.is_empty() {
1233 push_section_color(&mut lines, "issues", color);
1234 for service in &issue_services {
1235 let issue = service.issue.as_deref().unwrap_or("not ready");
1236 let detail = if report.action == LocalAction::Setup {
1237 format!(
1238 "{}: {issue} (log: {})",
1239 service.name,
1240 service.log_file.display()
1241 )
1242 } else {
1243 format!("{}: {issue}", service.name)
1244 };
1245 push_warning_bullet_color(&mut lines, detail, color);
1246 }
1247 }
1248
1249 if !unconfigured_services.is_empty() && report.action != LocalAction::Stop {
1250 push_section_color(&mut lines, "not configured", color);
1251 for service in &unconfigured_services {
1252 push_warning_bullet_color(&mut lines, &service.name, color);
1253 }
1254 }
1255
1256 push_section_color(&mut lines, "config", color);
1257 push_identity_bullet_color(&mut lines, report.config_file.display(), color);
1258
1259 if report.action == LocalAction::Setup && report.ready {
1260 push_section_color(&mut lines, "next", color);
1261 push_action_bullet_color(&mut lines, "kbolt collection add /path/to/docs", color);
1262 push_action_bullet_color(&mut lines, "kbolt doctor", color);
1263 }
1264
1265 lines.join("\n")
1266}
1267
1268fn format_field(color: bool, label: &str, value: &str) -> String {
1269 format!("{} {value}", paint_label(color, format!("{label}:")))
1270}
1271
1272fn push_section(lines: &mut Vec<String>, label: &str) {
1273 push_section_color(lines, label, false);
1274}
1275
1276fn push_section_color(lines: &mut Vec<String>, label: &str, color: bool) {
1277 if !lines.is_empty() {
1278 lines.push(String::new());
1279 }
1280 lines.push(paint_section(color, format!("{label}:")));
1281}
1282
1283fn push_bullet(lines: &mut Vec<String>, value: impl std::fmt::Display) {
1284 push_bullet_color(lines, value, false);
1285}
1286
1287fn push_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
1288 lines.push(format!("{} {value}", paint_chrome(color, "-")));
1289}
1290
1291fn push_field(lines: &mut Vec<String>, label: &str, value: impl std::fmt::Display) {
1292 push_field_color(lines, label, value, false);
1293}
1294
1295fn push_field_color(
1296 lines: &mut Vec<String>,
1297 label: &str,
1298 value: impl std::fmt::Display,
1299 color: bool,
1300) {
1301 lines.push(format!(
1302 " {} {value}",
1303 paint_label(color, format!("{label}:"))
1304 ));
1305}
1306
1307fn append_ignore_content_lines(lines: &mut Vec<String>, content: &str) {
1308 let mut added = false;
1309 for pattern in content.lines().filter(|line| !line.trim().is_empty()) {
1310 push_bullet(lines, pattern);
1311 added = true;
1312 }
1313 if !added {
1314 push_bullet(lines, "none");
1315 }
1316}
1317
1318fn paint(color: bool, style: CliStyle, text: impl AsRef<str>) -> String {
1319 paint_cli(color, style, text)
1320}
1321
1322pub fn paint_cli(color: bool, style: CliStyle, text: impl AsRef<str>) -> String {
1323 let text = text.as_ref();
1324 if !color || text.is_empty() {
1325 return text.to_string();
1326 }
1327
1328 match ansi_code(style) {
1329 Some(code) => format!("\x1b[{code}m{text}\x1b[0m"),
1330 None => text.to_string(),
1331 }
1332}
1333
1334fn paint_title(color: bool, text: impl AsRef<str>) -> String {
1335 paint(color, CliStyle::Title, text)
1336}
1337
1338fn paint_chrome(color: bool, text: impl AsRef<str>) -> String {
1339 paint(color, CliStyle::Chrome, text)
1340}
1341
1342fn paint_label(color: bool, text: impl AsRef<str>) -> String {
1343 paint(color, CliStyle::Label, text)
1344}
1345
1346fn paint_section(color: bool, text: impl AsRef<str>) -> String {
1347 paint(color, CliStyle::Section, text)
1348}
1349
1350fn paint_identity(color: bool, text: impl AsRef<str>) -> String {
1351 paint(color, CliStyle::Identity, text)
1352}
1353
1354fn paint_action(color: bool, text: impl AsRef<str>) -> String {
1355 paint(color, CliStyle::Action, text)
1356}
1357
1358fn paint_warning(color: bool, text: impl AsRef<str>) -> String {
1359 paint(color, CliStyle::Warning, text)
1360}
1361
1362fn paint_error(color: bool, text: impl AsRef<str>) -> String {
1363 paint(color, CliStyle::Error, text)
1364}
1365
1366fn push_action_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
1367 push_bullet_color(lines, paint_action(color, value.to_string()), color);
1368}
1369
1370fn push_action_hint_color(
1371 lines: &mut Vec<String>,
1372 label: &str,
1373 command: impl std::fmt::Display,
1374 color: bool,
1375) {
1376 push_bullet_color(
1377 lines,
1378 format!("{label}: {}", paint_action(color, command.to_string())),
1379 color,
1380 );
1381}
1382
1383fn push_warning_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
1384 push_bullet_color(lines, paint_warning(color, value.to_string()), color);
1385}
1386
1387fn push_warning_field_color(
1388 lines: &mut Vec<String>,
1389 label: &str,
1390 value: impl std::fmt::Display,
1391 color: bool,
1392) {
1393 lines.push(format!(
1394 " {} {}",
1395 paint_label(color, format!("{label}:")),
1396 paint_warning(color, value.to_string())
1397 ));
1398}
1399
1400fn push_error_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
1401 push_bullet_color(lines, paint_error(color, value.to_string()), color);
1402}
1403
1404fn push_error_field_color(
1405 lines: &mut Vec<String>,
1406 label: &str,
1407 value: impl std::fmt::Display,
1408 color: bool,
1409) {
1410 lines.push(format!(
1411 " {} {}",
1412 paint_label(color, format!("{label}:")),
1413 paint_error(color, value.to_string())
1414 ));
1415}
1416
1417fn push_identity_bullet_color(lines: &mut Vec<String>, value: impl std::fmt::Display, color: bool) {
1418 push_bullet_color(lines, paint_identity(color, value.to_string()), color);
1419}
1420
1421fn push_action_field_color(
1422 lines: &mut Vec<String>,
1423 label: &str,
1424 value: impl std::fmt::Display,
1425 color: bool,
1426) {
1427 lines.push(format!(
1428 " {} {}",
1429 paint_label(color, format!("{label}:")),
1430 paint_action(color, value.to_string())
1431 ));
1432}
1433
1434fn ansi_code(style: CliStyle) -> Option<&'static str> {
1435 match style {
1436 CliStyle::Title => Some("1"),
1437 CliStyle::Label => None,
1438 CliStyle::Chrome => Some("90"),
1439 CliStyle::Section => Some("1;36"),
1440 CliStyle::Identity => Some("34"),
1441 CliStyle::Action => Some("32"),
1442 CliStyle::Warning => Some("33"),
1443 CliStyle::Error => Some("31"),
1444 }
1445}
1446
1447fn format_search_mode(mode: &SearchMode) -> &'static str {
1448 match mode {
1449 SearchMode::Auto => "auto",
1450 SearchMode::Deep => "deep",
1451 SearchMode::Keyword => "keyword",
1452 SearchMode::Semantic => "semantic",
1453 }
1454}
1455
1456fn format_search_result_path(space: &str, path: &str) -> String {
1457 format!("{space}/{path}")
1458}
1459
1460fn format_space_describe_response(name: &str, color: bool) -> String {
1461 format!("space description updated: {}", paint_identity(color, name))
1462}
1463
1464fn format_space_add_response(name: &str, color: bool) -> String {
1465 format!("space added: {}", paint_identity(color, name))
1466}
1467
1468fn format_space_rename_response(old: &str, new: &str, color: bool) -> String {
1469 format!(
1470 "space renamed: {} -> {}",
1471 paint_identity(color, old),
1472 paint_identity(color, new)
1473 )
1474}
1475
1476fn format_space_remove_response(name: &str, color: bool) -> String {
1477 format!("removed: space {}", paint_identity(color, name))
1478}
1479
1480fn format_space_remove_default_response(color: bool) -> String {
1481 format!(
1482 "{}\n{} {}",
1483 format_space_remove_response("default", color),
1484 paint_label(color, "default space:"),
1485 paint_warning(color, "cleared")
1486 )
1487}
1488
1489fn format_space_default_response(name: Option<&str>, color: bool) -> String {
1490 match name {
1491 Some(name) => format!("default space: {}", paint_identity(color, name)),
1492 None => "default space: none".to_string(),
1493 }
1494}
1495
1496fn format_space_current_response(active: Option<(&str, &str)>, color: bool) -> String {
1497 match active {
1498 Some((name, source)) => format!(
1499 "active space: {} ({})",
1500 paint_identity(color, name),
1501 paint_chrome(color, source)
1502 ),
1503 None => "active space: none".to_string(),
1504 }
1505}
1506
1507fn group_search_results<'a>(
1508 results: &'a [kbolt_types::SearchResult],
1509 limit: usize,
1510) -> Vec<GroupedSearchResult<'a>> {
1511 let mut grouped: Vec<GroupedSearchResult<'a>> = Vec::new();
1512 let mut index_by_document: HashMap<(&str, &str), usize> = HashMap::new();
1513
1514 for result in results {
1515 let document_key = (result.space.as_str(), result.path.as_str());
1516 if let Some(index) = index_by_document.get(&document_key).copied() {
1517 grouped[index].additional_matches.push(result);
1518 continue;
1519 }
1520
1521 if grouped.len() >= limit {
1522 continue;
1523 }
1524
1525 let next_index = grouped.len();
1526 index_by_document.insert(document_key, next_index);
1527 grouped.push(GroupedSearchResult {
1528 primary: result,
1529 additional_matches: Vec::new(),
1530 });
1531 }
1532
1533 grouped
1534}
1535
1536fn format_additional_match_label(result: &kbolt_types::SearchResult) -> String {
1537 if let Some(heading) = result
1538 .heading
1539 .as_deref()
1540 .map(str::trim)
1541 .filter(|heading| !heading.is_empty())
1542 {
1543 return format!("{heading} (chunk {})", result.chunk.ordinal);
1544 }
1545
1546 format!("chunk {}", result.chunk.ordinal)
1547}
1548
1549fn format_compact_chunk_locator(docid: &str, chunk_ordinal: usize) -> String {
1550 format!("{}@{}", docid.trim(), chunk_ordinal)
1551}
1552
1553fn full_docid_from_chunk_locator(locator: &str) -> Option<&str> {
1554 locator
1555 .trim()
1556 .trim_start_matches('#')
1557 .split_once('@')
1558 .map(|(docid, _)| docid)
1559 .filter(|docid| !docid.is_empty())
1560}
1561
1562#[cfg(test)]
1563fn format_collection_info(collection: &CollectionInfo) -> String {
1564 format_collection_info_color(collection, false)
1565}
1566
1567fn format_collection_info_color(collection: &CollectionInfo, color: bool) -> String {
1568 let mut lines = Vec::new();
1569 lines.push(format!(
1570 "collection: {}/{}",
1571 collection.space, collection.name
1572 ));
1573 push_field_color(&mut lines, "path", collection.path.display(), color);
1574
1575 if let Some(description) = collection.description.as_deref() {
1576 if !description.is_empty() {
1577 push_field_color(&mut lines, "description", description, color);
1578 }
1579 }
1580
1581 if let Some(extensions) = collection.extensions.as_ref() {
1582 if !extensions.is_empty() {
1583 push_field_color(&mut lines, "extensions", extensions.join(", "), color);
1584 }
1585 }
1586
1587 push_section_color(&mut lines, "documents", color);
1588 push_bullet_color(
1589 &mut lines,
1590 format!(
1591 "{} active / {} total",
1592 collection.active_document_count, collection.document_count
1593 ),
1594 color,
1595 );
1596 push_bullet_color(
1597 &mut lines,
1598 format!("{} chunks", collection.chunk_count),
1599 color,
1600 );
1601 push_bullet_color(
1602 &mut lines,
1603 format!("{} embedded", collection.embedded_chunk_count),
1604 color,
1605 );
1606
1607 push_section_color(&mut lines, "timestamps", color);
1608 push_bullet_color(&mut lines, format!("created {}", collection.created), color);
1609 push_bullet_color(&mut lines, format!("updated {}", collection.updated), color);
1610
1611 lines.join("\n")
1612}
1613
1614fn format_document_response(document: &DocumentResponse, color: bool) -> String {
1615 let mut lines = Vec::new();
1616 lines.push(format!(
1617 "{} {}",
1618 paint_label(color, "document:"),
1619 paint_identity(
1620 color,
1621 format_search_result_path(&document.space, &document.path),
1622 )
1623 ));
1624 lines.push(format!(
1625 "{} {}",
1626 paint_label(color, "title:"),
1627 paint_title(color, &document.title)
1628 ));
1629 lines.push(format!(
1630 "{} {}",
1631 paint_label(color, "docid:"),
1632 paint_identity(color, &document.docid)
1633 ));
1634 if document.stale {
1635 lines.push(format!(
1636 "{} {}",
1637 paint_label(color, "status:"),
1638 paint_warning(color, "stale")
1639 ));
1640 }
1641
1642 lines.push(String::new());
1643 lines.push(document.content.clone());
1644
1645 lines.join("\n")
1646}
1647
1648fn format_chunk_response(chunk: &ChunkResponse, display_locator: &str, color: bool) -> String {
1649 let mut lines = Vec::new();
1650 lines.push(format!(
1651 "{} {}",
1652 paint_title(color, &chunk.title),
1653 paint_identity(color, display_locator)
1654 ));
1655 lines.push(format!(
1656 "{} {}",
1657 paint_label(color, "path:"),
1658 paint_identity(color, format_search_result_path(&chunk.space, &chunk.path))
1659 ));
1660 if let Some(heading) = chunk.heading.as_deref() {
1661 lines.push(format!("{} {heading}", paint_label(color, "section:")));
1662 }
1663 if chunk.stale {
1664 lines.push(format!(
1665 "{} {}",
1666 paint_label(color, "status:"),
1667 paint_warning(color, "stale")
1668 ));
1669 }
1670
1671 lines.push(String::new());
1672 lines.push(chunk.content.clone());
1673
1674 lines.join("\n")
1675}
1676
1677#[cfg(test)]
1678fn format_multi_get_response(response: &MultiGetResponse) -> String {
1679 format_multi_get_response_color(response, false)
1680}
1681
1682#[cfg(test)]
1683fn format_multi_get_response_color(response: &MultiGetResponse, color: bool) -> String {
1684 format_multi_get_response_color_with_chunk_locators(response, color, &HashMap::new())
1685}
1686
1687fn format_multi_get_response_color_with_chunk_locators(
1688 response: &MultiGetResponse,
1689 color: bool,
1690 chunk_display_locators: &HashMap<String, String>,
1691) -> String {
1692 let mut lines = Vec::new();
1693 push_section_color(&mut lines, "items", color);
1694 if response.items.is_empty() {
1695 push_bullet_color(&mut lines, "none returned", color);
1696 if response.resolved_count > 0 {
1697 push_bullet_color(
1698 &mut lines,
1699 format!("{} resolved", response.resolved_count),
1700 color,
1701 );
1702 }
1703 } else {
1704 push_bullet_color(
1705 &mut lines,
1706 format!("{} returned", response.items.len()),
1707 color,
1708 );
1709 if response.resolved_count > response.items.len() {
1710 push_bullet_color(
1711 &mut lines,
1712 format!("{} resolved", response.resolved_count),
1713 color,
1714 );
1715 }
1716
1717 for (index, item) in response.items.iter().enumerate() {
1718 lines.push(String::new());
1719 append_multi_get_item_lines(&mut lines, index + 1, item, color, chunk_display_locators);
1720 lines.push(String::new());
1721 }
1722 }
1723
1724 if !response.omitted.is_empty() {
1725 push_section_color(&mut lines, "omitted", color);
1726 for omitted in &response.omitted {
1727 push_warning_bullet_color(
1728 &mut lines,
1729 format_omitted_item(omitted, chunk_display_locators),
1730 color,
1731 );
1732 }
1733 }
1734
1735 if !response.warnings.is_empty() {
1736 push_section_color(&mut lines, "warnings", color);
1737 for warning in &response.warnings {
1738 push_warning_bullet_color(&mut lines, warning, color);
1739 }
1740 }
1741
1742 lines.join("\n")
1743}
1744
1745fn append_multi_get_item_lines(
1746 lines: &mut Vec<String>,
1747 index: usize,
1748 item: &MultiGetItem,
1749 color: bool,
1750 chunk_display_locators: &HashMap<String, String>,
1751) {
1752 match item {
1753 MultiGetItem::Document(document) => {
1754 lines.push(format!(
1755 "{}. {}",
1756 index,
1757 format_search_result_path(&document.space, &document.path)
1758 ));
1759 lines.push(format!(
1760 " {} {}",
1761 paint_label(color, "title:"),
1762 document.title
1763 ));
1764 lines.push(format!(
1765 " {} {}",
1766 paint_label(color, "docid:"),
1767 paint_identity(color, &document.docid)
1768 ));
1769 if document.stale {
1770 lines.push(format!(
1771 " {} {}",
1772 paint_label(color, "status:"),
1773 paint_warning(color, "stale")
1774 ));
1775 }
1776 append_plain_content_lines(lines, &document.content);
1777 }
1778 MultiGetItem::Chunk(chunk) => {
1779 let display_locator = chunk_display_locators
1780 .get(&chunk.locator)
1781 .map(String::as_str)
1782 .unwrap_or(&chunk.locator);
1783 lines.push(format!(
1784 "{}. {}",
1785 index,
1786 format_search_result_path(&chunk.space, &chunk.path)
1787 ));
1788 lines.push(format!(
1789 " {} {}",
1790 paint_label(color, "title:"),
1791 chunk.title
1792 ));
1793 lines.push(format!(
1794 " {} {}",
1795 paint_label(color, "locator:"),
1796 paint_identity(color, display_locator)
1797 ));
1798 if let Some(heading) = chunk.heading.as_deref() {
1799 lines.push(format!(" {} {}", paint_label(color, "section:"), heading));
1800 }
1801 if chunk.stale {
1802 lines.push(format!(
1803 " {} {}",
1804 paint_label(color, "status:"),
1805 paint_warning(color, "stale")
1806 ));
1807 }
1808 append_plain_content_lines(lines, &chunk.content);
1809 }
1810 }
1811}
1812
1813fn append_plain_content_lines(lines: &mut Vec<String>, content: &str) {
1814 lines.push(String::new());
1815 for content_line in content.lines() {
1816 lines.push(content_line.to_string());
1817 }
1818 if content.is_empty() {
1819 lines.push(String::new());
1820 }
1821}
1822
1823fn format_omitted_item(
1824 omitted: &kbolt_types::OmittedItem,
1825 chunk_display_locators: &HashMap<String, String>,
1826) -> String {
1827 match omitted.kind {
1828 MultiGetItemKind::Document => format!(
1829 "{} ({}, {})",
1830 omitted.path,
1831 format_bytes_human(omitted.size_bytes as u64),
1832 format_omit_reason(&omitted.reason)
1833 ),
1834 MultiGetItemKind::Chunk => {
1835 let display_locator = chunk_display_locators
1836 .get(&omitted.locator)
1837 .map(String::as_str)
1838 .unwrap_or(&omitted.locator);
1839 format!(
1840 "{} in {} ({}, {})",
1841 display_locator,
1842 omitted.path,
1843 format_bytes_human(omitted.size_bytes as u64),
1844 format_omit_reason(&omitted.reason)
1845 )
1846 }
1847 }
1848}
1849
1850#[cfg(test)]
1851fn format_models_list(status: &kbolt_types::ModelStatus) -> String {
1852 format_models_list_color(status, false)
1853}
1854
1855fn format_models_list_color(status: &kbolt_types::ModelStatus, color: bool) -> String {
1856 let mut lines = Vec::new();
1857 push_section_color(&mut lines, "models", color);
1858 append_model_status_lines(&mut lines, "embedder", &status.embedder, color);
1859 append_model_status_lines(&mut lines, "reranker", &status.reranker, color);
1860 append_model_status_lines(&mut lines, "expander", &status.expander, color);
1861 lines.join("\n")
1862}
1863
1864#[cfg(test)]
1865fn format_status_response(status: &StatusResponse, active_space: Option<&str>) -> String {
1866 format_status_response_color(status, active_space, false)
1867}
1868
1869fn format_status_response_color(
1870 status: &StatusResponse,
1871 active_space: Option<&str>,
1872 color: bool,
1873) -> String {
1874 let mut lines = Vec::new();
1875
1876 push_section_color(&mut lines, "spaces", color);
1877 if status.spaces.is_empty() {
1878 push_bullet_color(&mut lines, "none", color);
1879 } else {
1880 for space in &status.spaces {
1881 let active_suffix = if Some(space.name.as_str()) == active_space {
1882 " (active)"
1883 } else {
1884 ""
1885 };
1886 push_bullet_color(
1887 &mut lines,
1888 format!("{}{}", space.name, active_suffix),
1889 color,
1890 );
1891 if let Some(description) = space.description.as_deref() {
1892 if !description.is_empty() {
1893 push_field_color(&mut lines, "description", description, color);
1894 }
1895 }
1896 if let Some(last_updated) = space.last_updated.as_deref() {
1897 push_field_color(&mut lines, "updated", last_updated, color);
1898 }
1899
1900 if space.collections.is_empty() {
1901 push_field_color(&mut lines, "collections", "none", color);
1902 } else {
1903 lines.push(format!(" {}", paint_section(color, "collections:")));
1904 for collection in &space.collections {
1905 lines.push(format!(
1906 " {} {}",
1907 paint_chrome(color, "-"),
1908 collection.name
1909 ));
1910 lines.push(format!(
1911 " {} {}",
1912 paint_label(color, "path:"),
1913 collection.path.display()
1914 ));
1915 lines.push(format!(
1916 " {} {} active / {} total",
1917 paint_label(color, "documents:"),
1918 collection.active_documents,
1919 collection.documents
1920 ));
1921 lines.push(format!(
1922 " {} {}",
1923 paint_label(color, "chunks:"),
1924 collection.chunks
1925 ));
1926 lines.push(format!(
1927 " {} {}",
1928 paint_label(color, "embedded:"),
1929 collection.embedded_chunks
1930 ));
1931 lines.push(format!(
1932 " {} {}",
1933 paint_label(color, "updated:"),
1934 collection.last_updated
1935 ));
1936 }
1937 }
1938 }
1939 }
1940
1941 push_section_color(&mut lines, "totals", color);
1942 push_bullet_color(
1943 &mut lines,
1944 format!("documents: {}", status.total_documents),
1945 color,
1946 );
1947 push_bullet_color(
1948 &mut lines,
1949 format!("chunks: {}", status.total_chunks),
1950 color,
1951 );
1952 push_bullet_color(
1953 &mut lines,
1954 format!("embedded: {}", status.total_embedded),
1955 color,
1956 );
1957
1958 push_section_color(&mut lines, "storage", color);
1959 push_bullet_color(
1960 &mut lines,
1961 format!(
1962 "sqlite: {}",
1963 format_bytes_human(status.disk_usage.sqlite_bytes)
1964 ),
1965 color,
1966 );
1967 push_bullet_color(
1968 &mut lines,
1969 format!(
1970 "tantivy: {}",
1971 format_bytes_human(status.disk_usage.tantivy_bytes)
1972 ),
1973 color,
1974 );
1975 push_bullet_color(
1976 &mut lines,
1977 format!(
1978 "vectors: {}",
1979 format_bytes_human(status.disk_usage.usearch_bytes)
1980 ),
1981 color,
1982 );
1983 push_bullet_color(
1984 &mut lines,
1985 format!(
1986 "models: {}",
1987 format_bytes_human(status.disk_usage.models_bytes)
1988 ),
1989 color,
1990 );
1991 push_bullet_color(
1992 &mut lines,
1993 format!(
1994 "total: {}",
1995 format_bytes_human(status.disk_usage.total_bytes)
1996 ),
1997 color,
1998 );
1999
2000 push_section_color(&mut lines, "models", color);
2001 append_model_status_lines(&mut lines, "embedder", &status.models.embedder, color);
2002 append_model_status_lines(&mut lines, "reranker", &status.models.reranker, color);
2003 append_model_status_lines(&mut lines, "expander", &status.models.expander, color);
2004
2005 push_section_color(&mut lines, "paths", color);
2006 push_bullet_color(
2007 &mut lines,
2008 format!("cache: {}", status.cache_dir.display()),
2009 color,
2010 );
2011 push_bullet_color(
2012 &mut lines,
2013 format!("config: {}", status.config_dir.display()),
2014 color,
2015 );
2016
2017 lines.join("\n")
2018}
2019
2020#[cfg(test)]
2021fn format_file_list(files: &[FileEntry], all: bool) -> String {
2022 format_file_list_color(files, all, false)
2023}
2024
2025fn format_file_list_color(files: &[FileEntry], all: bool, color: bool) -> String {
2026 let mut lines = Vec::new();
2027 push_section_color(&mut lines, "files", color);
2028 if files.is_empty() {
2029 push_bullet_color(&mut lines, "none", color);
2030 return lines.join("\n");
2031 }
2032
2033 for file in files {
2034 push_bullet_color(&mut lines, format_file_entry_color(file, all, color), color);
2035 }
2036
2037 lines.join("\n")
2038}
2039
2040fn format_file_entry_color(file: &FileEntry, all: bool, color: bool) -> String {
2041 let path = paint_identity(color, &file.path);
2042 let status = if file.embedded {
2043 paint_chrome(color, "embedded")
2044 } else {
2045 paint_warning(color, "not embedded")
2046 };
2047
2048 if all && !file.active {
2049 format!(
2050 "{} ({}, {} chunk(s), {})",
2051 path,
2052 paint_warning(color, "inactive"),
2053 file.chunk_count,
2054 status
2055 )
2056 } else {
2057 format!("{} ({} chunk(s), {})", path, file.chunk_count, status)
2058 }
2059}
2060
2061fn append_model_status_lines(lines: &mut Vec<String>, label: &str, info: &ModelInfo, color: bool) {
2062 let mut summary = format!("{}: {}", label, format_model_state_label(info, color));
2063 if let Some(model) = info.model.as_deref() {
2064 summary.push_str(&format!(" ({model})"));
2065 }
2066 push_bullet_color(lines, summary, color);
2067
2068 if info.configured && !info.ready {
2069 if let Some(issue) = info.issue.as_deref() {
2070 push_warning_field_color(lines, "issue", issue, color);
2071 }
2072 }
2073}
2074
2075fn format_model_state_label(info: &ModelInfo, color: bool) -> String {
2076 let label = model_state_label(info);
2077 if info.ready {
2078 label.to_string()
2079 } else {
2080 paint_warning(color, label)
2081 }
2082}
2083
2084fn model_state_label(info: &ModelInfo) -> &'static str {
2085 if !info.configured {
2086 "not configured"
2087 } else if info.ready {
2088 "ready"
2089 } else {
2090 "not ready"
2091 }
2092}
2093
2094fn format_bytes_human(bytes: u64) -> String {
2095 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
2096
2097 if bytes < 1024 {
2098 return format!("{bytes} B");
2099 }
2100
2101 let mut value = bytes as f64;
2102 let mut unit_index = 0usize;
2103 while value >= 1024.0 && unit_index < UNITS.len() - 1 {
2104 value /= 1024.0;
2105 unit_index += 1;
2106 }
2107
2108 if value >= 10.0 {
2109 format!("{value:.0} {}", UNITS[unit_index])
2110 } else {
2111 format!("{value:.1} {}", UNITS[unit_index])
2112 }
2113}
2114
2115fn format_omit_reason(reason: &OmitReason) -> &'static str {
2116 match reason {
2117 OmitReason::MaxFiles => "max files",
2118 OmitReason::MaxBytes => "size limit",
2119 }
2120}
2121
2122fn active_space_name_for_status(engine: &Engine, explicit: Option<&str>) -> Option<String> {
2123 if let Some(space_name) = explicit {
2124 return Some(space_name.to_string());
2125 }
2126
2127 if let Ok(space_name) = std::env::var("KBOLT_SPACE") {
2128 let trimmed = space_name.trim();
2129 if !trimmed.is_empty() {
2130 return Some(trimmed.to_string());
2131 }
2132 }
2133
2134 engine.config().default_space.clone()
2135}
2136
2137fn format_optional_search_signal(value: Option<f32>) -> String {
2138 value
2139 .map(format_search_signal)
2140 .unwrap_or_else(|| "-".to_string())
2141}
2142
2143fn format_search_signal(value: f32) -> String {
2144 format!("{value:.2}")
2145}
2146
2147fn format_search_pipeline(pipeline: &SearchPipeline) -> String {
2148 let mut parts = Vec::new();
2149 if pipeline.expansion {
2150 parts.push("expansion");
2151 }
2152 if pipeline.keyword {
2153 parts.push("keyword");
2154 }
2155 if pipeline.dense {
2156 parts.push("dense");
2157 }
2158 if pipeline.rerank {
2159 parts.push("rerank");
2160 }
2161
2162 if parts.is_empty() {
2163 "none".to_string()
2164 } else {
2165 parts.join(" + ")
2166 }
2167}
2168
2169fn format_search_pipeline_notice(notice: &SearchPipelineNotice) -> String {
2170 let step = match notice.step {
2171 SearchPipelineStep::Dense => "dense retrieval",
2172 SearchPipelineStep::Rerank => "rerank",
2173 };
2174 let reason = match notice.reason {
2175 SearchPipelineUnavailableReason::NotConfigured => "not configured",
2176 SearchPipelineUnavailableReason::ModelNotAvailable => "required provider is not ready",
2177 };
2178 format!("{step} unavailable: {reason}")
2179}
2180
2181fn format_normal_search_pipeline_notice(
2182 notice: &SearchPipelineNotice,
2183 effective_mode: &SearchMode,
2184) -> &'static str {
2185 match notice.step {
2186 SearchPipelineStep::Dense if effective_mode == &SearchMode::Keyword => {
2187 "keyword only (dense unavailable)"
2188 }
2189 SearchPipelineStep::Dense => "dense unavailable",
2190 SearchPipelineStep::Rerank => "rerank skipped",
2191 }
2192}
2193
2194fn format_normal_search_pipeline_notice_color(
2195 notice: &SearchPipelineNotice,
2196 effective_mode: &SearchMode,
2197 color: bool,
2198) -> String {
2199 paint_warning(
2200 color,
2201 format_normal_search_pipeline_notice(notice, effective_mode),
2202 )
2203}
2204
2205#[cfg(test)]
2206fn format_update_report(report: &UpdateReport, verbose: bool, no_embed: bool) -> String {
2207 format_update_report_color(report, verbose, no_embed, false)
2208}
2209
2210fn format_update_report_color(
2211 report: &UpdateReport,
2212 verbose: bool,
2213 no_embed: bool,
2214 color: bool,
2215) -> String {
2216 let mut lines = Vec::new();
2217 if verbose {
2218 if !report.decisions.is_empty() {
2219 push_section_color(&mut lines, "decisions", color);
2220 for decision in &report.decisions {
2221 push_bullet_color(&mut lines, format_update_decision(decision), color);
2222 }
2223 }
2224
2225 let unreported_errors = unreported_update_errors(report);
2226 if !unreported_errors.is_empty() {
2227 push_section_color(&mut lines, "errors", color);
2228 for error in unreported_errors {
2229 push_error_bullet_color(
2230 &mut lines,
2231 format!("{}: {}", error.path, error.error),
2232 color,
2233 );
2234 }
2235 }
2236 }
2237
2238 if !lines.is_empty() {
2239 lines.push(String::new());
2240 }
2241 lines.push("update complete".to_string());
2242 append_update_summary_lines(&mut lines, report, no_embed, color);
2243
2244 if !report.errors.is_empty() && !verbose {
2245 let truncated = append_update_error_lines_color(&mut lines, report, 3, color);
2246 if truncated {
2247 push_section_color(&mut lines, "next", color);
2248 push_action_bullet_color(
2249 &mut lines,
2250 "run with --verbose for the full error list",
2251 color,
2252 );
2253 }
2254 }
2255
2256 lines.join("\n")
2257}
2258
2259fn format_collection_describe_response(name: &str, color: bool) -> String {
2260 format!(
2261 "collection description updated: {}",
2262 paint_identity(color, name)
2263 )
2264}
2265
2266fn format_collection_rename_response(old: &str, new: &str, color: bool) -> String {
2267 format!(
2268 "collection renamed: {} -> {}",
2269 paint_identity(color, old),
2270 paint_identity(color, new)
2271 )
2272}
2273
2274fn format_collection_remove_response(space: &str, name: &str, color: bool) -> String {
2275 format!(
2276 "removed: collection {}\n{} {}",
2277 paint_identity(color, name),
2278 paint_label(color, "space:"),
2279 paint_identity(color, space)
2280 )
2281}
2282
2283#[cfg(test)]
2284fn format_collection_add_result(result: &AddCollectionResult) -> String {
2285 format_collection_add_result_color(result, false)
2286}
2287
2288fn format_collection_add_result_color(result: &AddCollectionResult, color: bool) -> String {
2289 let collection = &result.collection;
2290 let locator = format!("{}/{}", collection.space, collection.name);
2291
2292 match &result.initial_indexing {
2293 InitialIndexingOutcome::Skipped => {
2294 let mut lines = vec![format!("collection added: {locator}")];
2295 push_section_color(&mut lines, "indexing", color);
2296 push_warning_bullet_color(&mut lines, "skipped (--no-index)", color);
2297 push_section_color(&mut lines, "next", color);
2298 push_action_bullet_color(
2299 &mut lines,
2300 format!(
2301 "kbolt --space {} update --collection {}",
2302 shell_quote_arg(&collection.space),
2303 shell_quote_arg(&collection.name),
2304 ),
2305 color,
2306 );
2307 lines.join("\n")
2308 }
2309 InitialIndexingOutcome::Indexed(report) => {
2310 format_collection_add_indexing_report(collection, &locator, report, color)
2311 }
2312 InitialIndexingOutcome::Blocked(block) => {
2313 format_collection_add_block(collection, &locator, block, color)
2314 }
2315 }
2316}
2317
2318fn format_collection_add_indexing_report(
2319 collection: &kbolt_types::CollectionInfo,
2320 locator: &str,
2321 report: &UpdateReport,
2322 color: bool,
2323) -> String {
2324 let mut lines = Vec::new();
2325 if report.failed_docs == 0 {
2326 lines.push(format!("collection added and indexed: {locator}"));
2327 } else {
2328 lines.push(format!("collection added: {locator}"));
2329 lines.push("initial indexing incomplete".to_string());
2330 }
2331
2332 append_update_summary_lines(&mut lines, report, false, color);
2333
2334 let truncated = if !report.errors.is_empty() {
2335 append_update_error_lines_color(&mut lines, report, 3, color)
2336 } else {
2337 false
2338 };
2339
2340 if report.failed_docs > 0 || truncated {
2341 push_section_color(&mut lines, "next", color);
2342 let verbose_flag = if truncated { " --verbose" } else { "" };
2343 push_action_bullet_color(
2344 &mut lines,
2345 format!(
2346 "kbolt --space {} update{verbose_flag} --collection {}",
2347 shell_quote_arg(&collection.space),
2348 shell_quote_arg(&collection.name),
2349 ),
2350 color,
2351 );
2352 }
2353
2354 lines.join("\n")
2355}
2356
2357fn format_collection_add_block(
2358 collection: &kbolt_types::CollectionInfo,
2359 locator: &str,
2360 block: &InitialIndexingBlock,
2361 color: bool,
2362) -> String {
2363 let mut lines = Vec::new();
2364 lines.push(format!("collection added: {locator}"));
2365
2366 match block {
2367 InitialIndexingBlock::SpaceDenseRepairRequired { space, reason } => {
2368 lines.push(format!(
2369 "indexing blocked by a dense integrity issue in space '{space}'"
2370 ));
2371 push_field_color(&mut lines, "reason", reason, color);
2372 push_section_color(&mut lines, "next", color);
2373 push_action_bullet_color(
2374 &mut lines,
2375 format!("kbolt --space {} update", shell_quote_arg(space)),
2376 color,
2377 );
2378 }
2379 InitialIndexingBlock::ModelNotAvailable { name } => {
2380 lines.push(format!("indexing blocked: model '{name}' is not available"));
2381 push_section_color(&mut lines, "next", color);
2382 push_action_bullet_color(&mut lines, "kbolt setup local", color);
2383 push_bullet_color(
2384 &mut lines,
2385 "or configure [roles.embedder] in index.toml",
2386 color,
2387 );
2388 push_action_hint_color(
2389 &mut lines,
2390 "then run",
2391 format!(
2392 "kbolt --space {} update --collection {}",
2393 shell_quote_arg(&collection.space),
2394 shell_quote_arg(&collection.name),
2395 ),
2396 color,
2397 );
2398 }
2399 }
2400
2401 lines.join("\n")
2402}
2403
2404#[cfg(test)]
2405fn append_update_error_lines(lines: &mut Vec<String>, report: &UpdateReport, limit: usize) -> bool {
2406 append_update_error_lines_color(lines, report, limit, false)
2407}
2408
2409fn append_update_error_lines_color(
2410 lines: &mut Vec<String>,
2411 report: &UpdateReport,
2412 limit: usize,
2413 color: bool,
2414) -> bool {
2415 push_section_color(lines, "errors", color);
2416 for error in report.errors.iter().take(limit) {
2417 push_error_bullet_color(lines, format!("{}: {}", error.path, error.error), color);
2418 }
2419 let truncated = report.errors.len() > limit;
2420 if truncated {
2421 push_error_bullet_color(
2422 lines,
2423 format!("{} more error(s)", report.errors.len() - limit),
2424 color,
2425 );
2426 }
2427 truncated
2428}
2429
2430fn append_update_summary_lines(
2431 lines: &mut Vec<String>,
2432 report: &UpdateReport,
2433 no_embed: bool,
2434 color: bool,
2435) {
2436 push_section_color(lines, "summary", color);
2437 push_bullet_color(
2438 lines,
2439 format!("{} document(s) scanned", report.scanned_docs),
2440 color,
2441 );
2442
2443 let unchanged = report.skipped_mtime_docs + report.skipped_hash_docs;
2444 if unchanged > 0 {
2445 push_bullet_color(lines, format!("{} unchanged", unchanged), color);
2446 }
2447 if report.added_docs > 0 {
2448 push_bullet_color(lines, format!("{} added", report.added_docs), color);
2449 }
2450 if report.updated_docs > 0 {
2451 push_bullet_color(lines, format!("{} updated", report.updated_docs), color);
2452 }
2453 if report.failed_docs > 0 {
2454 push_error_bullet_color(lines, format!("{} failed", report.failed_docs), color);
2455 }
2456 if report.deactivated_docs > 0 {
2457 push_bullet_color(
2458 lines,
2459 format!("{} deactivated", report.deactivated_docs),
2460 color,
2461 );
2462 }
2463 if report.reactivated_docs > 0 {
2464 push_bullet_color(
2465 lines,
2466 format!("{} reactivated", report.reactivated_docs),
2467 color,
2468 );
2469 }
2470 if report.reaped_docs > 0 {
2471 push_bullet_color(lines, format!("{} reaped", report.reaped_docs), color);
2472 }
2473 if report.embedded_chunks > 0 {
2474 push_bullet_color(
2475 lines,
2476 format!("{} chunk(s) embedded", report.embedded_chunks),
2477 color,
2478 );
2479 }
2480 if no_embed {
2481 push_warning_bullet_color(lines, "embedding skipped (--no-embed)", color);
2482 }
2483
2484 push_bullet_color(
2485 lines,
2486 format!("completed in {}", format_elapsed_ms(report.elapsed_ms)),
2487 color,
2488 );
2489}
2490
2491fn format_elapsed_ms(elapsed_ms: u64) -> String {
2492 if elapsed_ms < 1_000 {
2493 format!("{elapsed_ms}ms")
2494 } else if elapsed_ms < 60_000 {
2495 format!("{:.1}s", elapsed_ms as f64 / 1_000.0)
2496 } else {
2497 let total_seconds = elapsed_ms as f64 / 1_000.0;
2498 format!("{:.1}m", total_seconds / 60.0)
2499 }
2500}
2501
2502fn format_update_decision(decision: &UpdateDecision) -> String {
2503 let locator = format!(
2504 "{}/{}/{}",
2505 decision.space, decision.collection, decision.path
2506 );
2507 match decision.detail.as_deref() {
2508 Some(detail) => format!(
2509 "{locator}: {} ({detail})",
2510 format_update_decision_kind(&decision.kind)
2511 ),
2512 None => format!("{locator}: {}", format_update_decision_kind(&decision.kind)),
2513 }
2514}
2515
2516fn format_update_decision_kind(kind: &UpdateDecisionKind) -> &'static str {
2517 match kind {
2518 UpdateDecisionKind::New => "new",
2519 UpdateDecisionKind::Changed => "changed",
2520 UpdateDecisionKind::SkippedMtime => "skipped_mtime",
2521 UpdateDecisionKind::SkippedHash => "skipped_hash",
2522 UpdateDecisionKind::Ignored => "ignored",
2523 UpdateDecisionKind::Unsupported => "unsupported",
2524 UpdateDecisionKind::ReadFailed => "read_failed",
2525 UpdateDecisionKind::ExtractFailed => "extract_failed",
2526 UpdateDecisionKind::Reactivated => "reactivated",
2527 UpdateDecisionKind::Deactivated => "deactivated",
2528 }
2529}
2530
2531fn unreported_update_errors(report: &UpdateReport) -> Vec<&kbolt_types::FileError> {
2532 report
2533 .errors
2534 .iter()
2535 .filter(|error| {
2536 !report.decisions.iter().any(|decision| {
2537 matches!(
2538 decision.kind,
2539 UpdateDecisionKind::ReadFailed | UpdateDecisionKind::ExtractFailed
2540 ) && std::path::Path::new(&error.path)
2541 .ends_with(std::path::Path::new(&decision.path))
2542 })
2543 })
2544 .collect()
2545}
2546
2547pub fn resolve_no_rerank_for_mode(mode: SearchMode, rerank: bool, no_rerank: bool) -> bool {
2548 match mode {
2549 SearchMode::Auto => !rerank,
2550 SearchMode::Deep => no_rerank,
2551 SearchMode::Keyword | SearchMode::Semantic => true,
2552 }
2553}
2554
2555fn truncate_snippet(text: &str, max_lines: usize) -> String {
2556 let lines: Vec<&str> = text.lines().collect();
2557 if lines.len() <= max_lines {
2558 return text.to_string();
2559 }
2560 let truncated: Vec<&str> = lines[..max_lines].to_vec();
2561 let remaining = lines.len() - max_lines;
2562 format!(
2563 "{}\n(+{remaining} more line{})",
2564 truncated.join("\n"),
2565 if remaining == 1 { "" } else { "s" }
2566 )
2567}
2568
2569fn resolve_editor_command() -> Result<Vec<String>> {
2570 let raw = std::env::var("VISUAL")
2571 .ok()
2572 .filter(|value| !value.trim().is_empty())
2573 .or_else(|| {
2574 std::env::var("EDITOR")
2575 .ok()
2576 .filter(|value| !value.trim().is_empty())
2577 })
2578 .unwrap_or_else(|| "vi".to_string());
2579
2580 parse_editor_command(&raw)
2581}
2582
2583fn parse_editor_command(raw: &str) -> Result<Vec<String>> {
2584 let args = shell_words::split(raw).map_err(|err| {
2585 KboltError::InvalidInput(format!("invalid editor command '{raw}': {err}"))
2586 })?;
2587 if args.is_empty() {
2588 return Err(KboltError::InvalidInput("editor command cannot be empty".to_string()).into());
2589 }
2590 Ok(args)
2591}
2592
2593#[cfg(test)]
2594fn format_schedule_add_response(response: &ScheduleAddResponse) -> String {
2595 format_schedule_add_response_color(response, false)
2596}
2597
2598fn format_schedule_add_response_color(response: &ScheduleAddResponse, color: bool) -> String {
2599 let mut lines = Vec::new();
2600 lines.push(format!("schedule added: {}", response.schedule.id));
2601 push_section_color(&mut lines, "details", color);
2602 push_bullet_color(
2603 &mut lines,
2604 format!(
2605 "trigger: {}",
2606 format_schedule_trigger(&response.schedule.trigger)
2607 ),
2608 color,
2609 );
2610 push_bullet_color(
2611 &mut lines,
2612 format!("scope: {}", format_schedule_scope(&response.schedule.scope)),
2613 color,
2614 );
2615 push_bullet_color(
2616 &mut lines,
2617 format!("backend: {}", format_schedule_backend(response.backend)),
2618 color,
2619 );
2620 lines.join("\n")
2621}
2622
2623#[cfg(test)]
2624fn format_schedule_status_response(response: &ScheduleStatusResponse) -> String {
2625 format_schedule_status_response_color(response, false)
2626}
2627
2628fn format_schedule_status_response_color(response: &ScheduleStatusResponse, color: bool) -> String {
2629 let mut lines = Vec::new();
2630 push_section_color(&mut lines, "schedules", color);
2631 if response.schedules.is_empty() {
2632 push_bullet_color(&mut lines, "none", color);
2633 } else {
2634 for entry in &response.schedules {
2635 push_bullet_color(&mut lines, &entry.schedule.id, color);
2636 push_field_color(
2637 &mut lines,
2638 "trigger",
2639 format_schedule_trigger(&entry.schedule.trigger),
2640 color,
2641 );
2642 push_field_color(
2643 &mut lines,
2644 "scope",
2645 format_schedule_scope(&entry.schedule.scope),
2646 color,
2647 );
2648 push_field_color(
2649 &mut lines,
2650 "backend",
2651 format_schedule_backend(entry.backend),
2652 color,
2653 );
2654 push_field_color(
2655 &mut lines,
2656 "state",
2657 format_schedule_state_color(entry.state, color),
2658 color,
2659 );
2660 push_field_color(
2661 &mut lines,
2662 "last started",
2663 entry.run_state.last_started.as_deref().unwrap_or("never"),
2664 color,
2665 );
2666 push_field_color(
2667 &mut lines,
2668 "last finished",
2669 entry.run_state.last_finished.as_deref().unwrap_or("never"),
2670 color,
2671 );
2672 push_field_color(
2673 &mut lines,
2674 "last result",
2675 format_schedule_run_result_color(entry.run_state.last_result, color),
2676 color,
2677 );
2678 if let Some(error) = entry.run_state.last_error.as_deref() {
2679 push_error_field_color(&mut lines, "last error", error, color);
2680 }
2681 }
2682 }
2683
2684 push_section_color(&mut lines, "orphans", color);
2685 if response.orphans.is_empty() {
2686 push_bullet_color(&mut lines, "none", color);
2687 } else {
2688 for orphan in &response.orphans {
2689 push_warning_bullet_color(
2690 &mut lines,
2691 format!(
2692 "{} ({})",
2693 orphan.id,
2694 format_schedule_backend(orphan.backend)
2695 ),
2696 color,
2697 );
2698 }
2699 }
2700
2701 lines.join("\n")
2702}
2703
2704#[cfg(test)]
2705fn format_schedule_remove_response(
2706 selector: &RemoveScheduleSelector,
2707 response: &kbolt_types::ScheduleRemoveResponse,
2708) -> String {
2709 format_schedule_remove_response_color(selector, response, false)
2710}
2711
2712fn format_schedule_remove_response_color(
2713 selector: &RemoveScheduleSelector,
2714 response: &kbolt_types::ScheduleRemoveResponse,
2715 color: bool,
2716) -> String {
2717 let mut lines = Vec::with_capacity(3);
2718 match selector {
2719 RemoveScheduleSelector::Id { id } => {
2720 let removed = response.removed_ids.first().map_or(id, |removed| removed);
2721 if response.removed_ids.is_empty() {
2722 lines.push("removed: no schedule".to_string());
2723 lines.push(format!(
2724 "{} {}",
2725 paint_label(color, "schedule:"),
2726 paint_identity(color, removed)
2727 ));
2728 } else {
2729 lines.push(format!(
2730 "removed: schedule {}",
2731 paint_identity(color, removed)
2732 ));
2733 }
2734 }
2735 RemoveScheduleSelector::All => {
2736 let summary = if response.removed_ids.is_empty() {
2737 "removed: no schedules"
2738 } else {
2739 "removed: all schedules"
2740 };
2741 lines.push(summary.to_string());
2742 lines.push(format!("{} all spaces", paint_label(color, "scope:")));
2743 }
2744 RemoveScheduleSelector::Scope { scope } => {
2745 let summary = if response.removed_ids.is_empty() {
2746 "removed: no schedules"
2747 } else {
2748 "removed: schedules"
2749 };
2750 lines.push(summary.to_string());
2751 append_schedule_scope_lines(&mut lines, scope, color);
2752 }
2753 }
2754
2755 lines.join("\n")
2756}
2757
2758fn append_schedule_scope_lines(lines: &mut Vec<String>, scope: &ScheduleScope, color: bool) {
2759 match scope {
2760 ScheduleScope::All => lines.push(format!("{} all spaces", paint_label(color, "scope:"))),
2761 ScheduleScope::Space { space } => lines.push(format!(
2762 "{} {}",
2763 paint_label(color, "space:"),
2764 paint_identity(color, space)
2765 )),
2766 ScheduleScope::Collections { space, collections } => {
2767 lines.push(format!(
2768 "{} {}",
2769 paint_label(color, "space:"),
2770 paint_identity(color, space)
2771 ));
2772 let collection_names = collections
2773 .iter()
2774 .map(|collection| paint_identity(color, collection))
2775 .collect::<Vec<_>>()
2776 .join(", ");
2777 lines.push(format!(
2778 "{} {collection_names}",
2779 paint_label(color, "collections:")
2780 ));
2781 }
2782 }
2783}
2784
2785fn format_schedule_trigger(trigger: &ScheduleTrigger) -> String {
2786 match trigger {
2787 ScheduleTrigger::Every { interval } => format_schedule_interval(interval),
2788 ScheduleTrigger::Daily { time } => format!("daily at {}", format_schedule_time(time)),
2789 ScheduleTrigger::Weekly { weekdays, time } => format!(
2790 "{} at {}",
2791 format_schedule_weekdays(weekdays),
2792 format_schedule_time(time)
2793 ),
2794 }
2795}
2796
2797fn format_schedule_interval(interval: &ScheduleInterval) -> String {
2798 let suffix = match interval.unit {
2799 ScheduleIntervalUnit::Minutes => "m",
2800 ScheduleIntervalUnit::Hours => "h",
2801 };
2802 format!("every {}{suffix}", interval.value)
2803}
2804
2805fn format_schedule_scope(scope: &ScheduleScope) -> String {
2806 match scope {
2807 ScheduleScope::All => "all spaces".to_string(),
2808 ScheduleScope::Space { space } => format!("space {space}"),
2809 ScheduleScope::Collections { space, collections } => collections
2810 .iter()
2811 .map(|collection| format!("{space}/{collection}"))
2812 .collect::<Vec<_>>()
2813 .join(", "),
2814 }
2815}
2816
2817fn format_schedule_backend(backend: ScheduleBackend) -> &'static str {
2818 match backend {
2819 ScheduleBackend::Launchd => "launchd",
2820 ScheduleBackend::SystemdUser => "systemd-user",
2821 }
2822}
2823
2824fn format_schedule_state(state: ScheduleState) -> &'static str {
2825 match state {
2826 ScheduleState::Installed => "installed",
2827 ScheduleState::Drifted => "drifted",
2828 ScheduleState::TargetMissing => "target missing",
2829 }
2830}
2831
2832fn format_schedule_state_color(state: ScheduleState, color: bool) -> String {
2833 let label = format_schedule_state(state);
2834 match state {
2835 ScheduleState::Installed => label.to_string(),
2836 ScheduleState::Drifted | ScheduleState::TargetMissing => paint_warning(color, label),
2837 }
2838}
2839
2840fn format_schedule_run_result(result: Option<ScheduleRunResult>) -> &'static str {
2841 match result {
2842 Some(ScheduleRunResult::Success) => "success",
2843 Some(ScheduleRunResult::SkippedLock) => "skipped lock",
2844 Some(ScheduleRunResult::Failed) => "failed",
2845 None => "never",
2846 }
2847}
2848
2849fn format_schedule_run_result_color(result: Option<ScheduleRunResult>, color: bool) -> String {
2850 let label = format_schedule_run_result(result);
2851 match result {
2852 Some(ScheduleRunResult::SkippedLock) => paint_warning(color, label),
2853 Some(ScheduleRunResult::Failed) => paint_error(color, label),
2854 Some(ScheduleRunResult::Success) | None => label.to_string(),
2855 }
2856}
2857
2858fn format_schedule_weekdays(weekdays: &[ScheduleWeekday]) -> String {
2859 weekdays
2860 .iter()
2861 .map(|weekday| match weekday {
2862 ScheduleWeekday::Mon => "mon",
2863 ScheduleWeekday::Tue => "tue",
2864 ScheduleWeekday::Wed => "wed",
2865 ScheduleWeekday::Thu => "thu",
2866 ScheduleWeekday::Fri => "fri",
2867 ScheduleWeekday::Sat => "sat",
2868 ScheduleWeekday::Sun => "sun",
2869 })
2870 .collect::<Vec<_>>()
2871 .join(", ")
2872}
2873
2874fn format_schedule_time(time: &str) -> String {
2875 let Some((hour, minute)) = time.split_once(':') else {
2876 return time.to_string();
2877 };
2878 let Ok(mut hour) = hour.parse::<u32>() else {
2879 return time.to_string();
2880 };
2881 let Ok(minute) = minute.parse::<u32>() else {
2882 return time.to_string();
2883 };
2884
2885 let meridiem = if hour >= 12 { "PM" } else { "AM" };
2886 if hour == 0 {
2887 hour = 12;
2888 } else if hour > 12 {
2889 hour -= 12;
2890 }
2891
2892 format!("{hour}:{minute:02} {meridiem}")
2893}
2894
2895#[cfg(test)]
2896fn format_eval_run_report(report: &EvalRunReport) -> String {
2897 format_eval_run_report_color(report, false)
2898}
2899
2900fn format_eval_run_report_color(report: &EvalRunReport, color: bool) -> String {
2901 let mut lines = vec!["eval complete".to_string()];
2902
2903 push_section_color(&mut lines, "summary", color);
2904 push_bullet_color(&mut lines, format!("{} case(s)", report.total_cases), color);
2905
2906 push_section_color(&mut lines, "modes", color);
2907 for mode in &report.modes {
2908 push_bullet_color(
2909 &mut lines,
2910 format_eval_mode_label(&mode.mode, mode.no_rerank),
2911 color,
2912 );
2913 push_field_color(
2914 &mut lines,
2915 "nDCG@10",
2916 format!("{:.3}", mode.ndcg_at_10),
2917 color,
2918 );
2919 push_field_color(
2920 &mut lines,
2921 "Recall@10",
2922 format!("{:.3}", mode.recall_at_10),
2923 color,
2924 );
2925 push_field_color(
2926 &mut lines,
2927 "MRR@10",
2928 format!("{:.3}", mode.mrr_at_10),
2929 color,
2930 );
2931 push_field_color(
2932 &mut lines,
2933 "p50",
2934 format!("{}ms", mode.latency_p50_ms),
2935 color,
2936 );
2937 push_field_color(
2938 &mut lines,
2939 "p95",
2940 format!("{}ms", mode.latency_p95_ms),
2941 color,
2942 );
2943 }
2944
2945 if !report.failed_modes.is_empty() {
2946 push_section_color(&mut lines, "failed", color);
2947 for failure in &report.failed_modes {
2948 push_error_bullet_color(
2949 &mut lines,
2950 format!(
2951 "{}: {}",
2952 format_eval_mode_label(&failure.mode, failure.no_rerank),
2953 failure.error
2954 ),
2955 color,
2956 );
2957 }
2958 }
2959
2960 push_section_color(&mut lines, "attention", color);
2961 let mut has_findings = false;
2962 for mode in &report.modes {
2963 for query in &mode.queries {
2964 let perfect_recall = query.matched_paths.len() == relevant_judgment_count(query);
2965 let perfect_rank = query.first_relevant_rank == Some(1);
2966 if perfect_recall && perfect_rank {
2967 continue;
2968 }
2969
2970 has_findings = true;
2971 push_bullet_color(
2972 &mut lines,
2973 format!(
2974 "{}: {}",
2975 format_eval_mode_label(&mode.mode, mode.no_rerank),
2976 query.query
2977 ),
2978 color,
2979 );
2980 push_field_color(
2981 &mut lines,
2982 "first relevant",
2983 query
2984 .first_relevant_rank
2985 .map(|rank| rank.to_string())
2986 .unwrap_or_else(|| "none".to_string()),
2987 color,
2988 );
2989 push_field_color(
2990 &mut lines,
2991 "expected",
2992 format_eval_judgments(&query.judgments),
2993 color,
2994 );
2995 push_field_color(
2996 &mut lines,
2997 "returned",
2998 if query.returned_paths.is_empty() {
2999 "none".to_string()
3000 } else {
3001 query.returned_paths.join(", ")
3002 },
3003 color,
3004 );
3005 }
3006 }
3007 if !has_findings {
3008 push_bullet_color(&mut lines, "none", color);
3009 }
3010
3011 lines.join("\n")
3012}
3013
3014pub fn format_eval_import_report(report: &EvalImportReport) -> String {
3015 format_eval_import_report_color(report, false)
3016}
3017
3018pub fn format_eval_import_report_color(report: &EvalImportReport, color: bool) -> String {
3019 let mut lines = Vec::new();
3020 lines.push(format!("benchmark imported: {}", report.dataset));
3021
3022 push_section_color(&mut lines, "paths", color);
3023 push_bullet_color(
3024 &mut lines,
3025 format!("source: {}", paint_identity(color, &report.source)),
3026 color,
3027 );
3028 push_bullet_color(
3029 &mut lines,
3030 format!("output: {}", paint_identity(color, &report.output_dir)),
3031 color,
3032 );
3033 push_bullet_color(
3034 &mut lines,
3035 format!("corpus: {}", paint_identity(color, &report.corpus_dir)),
3036 color,
3037 );
3038 push_bullet_color(
3039 &mut lines,
3040 format!("manifest: {}", paint_identity(color, &report.manifest_path)),
3041 color,
3042 );
3043
3044 push_section_color(&mut lines, "contents", color);
3045 push_bullet_color(
3046 &mut lines,
3047 format!("{} document(s)", report.document_count),
3048 color,
3049 );
3050 push_bullet_color(
3051 &mut lines,
3052 format!("{} query(s)", report.query_count),
3053 color,
3054 );
3055 push_bullet_color(
3056 &mut lines,
3057 format!("{} judgment(s)", report.judgment_count),
3058 color,
3059 );
3060
3061 push_section_color(&mut lines, "next", color);
3062 push_action_hint_color(
3063 &mut lines,
3064 "create the benchmark space if needed",
3065 format!("kbolt space add {}", shell_quote_arg(&report.default_space)),
3066 color,
3067 );
3068 push_action_hint_color(
3069 &mut lines,
3070 "register the corpus",
3071 format!(
3072 "kbolt --space {} collection add {} --name {} --no-index",
3073 shell_quote_arg(&report.default_space),
3074 shell_quote_arg(&report.corpus_dir),
3075 shell_quote_arg(&report.collection),
3076 ),
3077 color,
3078 );
3079 push_action_hint_color(
3080 &mut lines,
3081 "index it",
3082 format!(
3083 "kbolt --space {} update --collection {}",
3084 shell_quote_arg(&report.default_space),
3085 shell_quote_arg(&report.collection),
3086 ),
3087 color,
3088 );
3089 push_action_hint_color(
3090 &mut lines,
3091 "run eval",
3092 format!(
3093 "kbolt eval run --file {}",
3094 shell_quote_arg(&report.manifest_path)
3095 ),
3096 color,
3097 );
3098
3099 lines.join("\n")
3100}
3101
3102fn relevant_judgment_count(query: &kbolt_types::EvalQueryReport) -> usize {
3103 query
3104 .judgments
3105 .iter()
3106 .filter(|judgment| judgment.relevance > 0)
3107 .count()
3108}
3109
3110fn format_eval_judgments(judgments: &[kbolt_types::EvalJudgment]) -> String {
3111 judgments
3112 .iter()
3113 .map(|judgment| format!("{}(rel={})", judgment.path, judgment.relevance))
3114 .collect::<Vec<_>>()
3115 .join(", ")
3116}
3117
3118fn format_eval_mode_label(mode: &SearchMode, no_rerank: bool) -> &'static str {
3119 match (mode, no_rerank) {
3120 (SearchMode::Keyword, _) => "keyword",
3121 (SearchMode::Auto, true) => "auto",
3122 (SearchMode::Auto, false) => "auto+rerank",
3123 (SearchMode::Semantic, _) => "semantic",
3124 (SearchMode::Deep, true) => "deep-norerank",
3125 (SearchMode::Deep, false) => "deep",
3126 }
3127}
3128
3129fn shell_quote_arg(arg: &str) -> String {
3133 let is_safe = !arg.is_empty()
3134 && arg.chars().all(|c| {
3135 c.is_ascii_alphanumeric()
3136 || matches!(c, '-' | '_' | '.' | '/' | ',' | ':' | '+' | '@' | '=')
3137 });
3138 if is_safe {
3139 arg.to_string()
3140 } else {
3141 format!("'{}'", arg.replace('\'', "'\\''"))
3142 }
3143}
3144
3145fn format_chunk_read_command(space: &str, locator: &str) -> String {
3146 format!(
3147 "kbolt --space {} get {}",
3148 shell_quote_arg(space),
3149 shell_quote_arg(locator)
3150 )
3151}
3152
3153#[cfg(test)]
3154mod tests {
3155 use std::ffi::OsString;
3156 use std::sync::{Mutex, OnceLock};
3157 use std::{
3158 fs,
3159 path::{Path, PathBuf},
3160 };
3161
3162 use tempfile::tempdir;
3163
3164 use super::{
3165 active_space_name_for_status, ansi_code, append_update_error_lines,
3166 format_additional_match_label, format_chunk_read_command, format_chunk_response,
3167 format_collection_add_result, format_collection_add_result_color,
3168 format_collection_describe_response, format_collection_info,
3169 format_collection_remove_response, format_collection_rename_response,
3170 format_compact_chunk_locator, format_doctor_report, format_document_response,
3171 format_elapsed_ms, format_eval_import_report, format_eval_import_report_color,
3172 format_eval_run_report, format_eval_run_report_color, format_file_list,
3173 format_file_list_color, format_local_report, format_models_list, format_models_list_color,
3174 format_multi_get_response, format_multi_get_response_color,
3175 format_multi_get_response_color_with_chunk_locators, format_optional_search_signal,
3176 format_schedule_add_response, format_schedule_remove_response,
3177 format_schedule_remove_response_color, format_schedule_status_response,
3178 format_schedule_status_response_color, format_search_result_path,
3179 format_space_add_response, format_space_current_response, format_space_default_response,
3180 format_space_describe_response, format_space_remove_default_response,
3181 format_space_remove_response, format_space_rename_response, format_status_response,
3182 format_update_report, format_update_report_color, group_search_results,
3183 is_expected_unindexed_storage_warning, paint, paint_action, paint_error, paint_identity,
3184 paint_warning, parse_editor_command, resolve_editor_command, resolve_no_rerank_for_mode,
3185 shell_quote_arg, truncate_snippet, CliAdapter, CliGetOptions, CliSearchOptions, CliStyle,
3186 };
3187 use kbolt_core::engine::Engine;
3188 use kbolt_types::{
3189 AddCollectionRequest, AddCollectionResult, ChunkResponse, CollectionInfo, CollectionStatus,
3190 DiskUsage, DoctorCheck, DoctorCheckStatus, DoctorReport, DoctorSetupStatus,
3191 DocumentResponse, EvalImportReport, EvalJudgment, EvalModeReport, EvalQueryReport,
3192 EvalRunReport, FileEntry, FileError, InitialIndexingBlock, InitialIndexingOutcome,
3193 LocalAction, LocalReport, ModelInfo, MultiGetItem, MultiGetItemKind, MultiGetResponse,
3194 OmitReason, OmittedItem, RemoveScheduleSelector, ScheduleAddResponse, ScheduleBackend,
3195 ScheduleDefinition, ScheduleInterval, ScheduleIntervalUnit, ScheduleOrphan,
3196 ScheduleRemoveResponse, ScheduleRunResult, ScheduleRunState, ScheduleScope, ScheduleState,
3197 ScheduleStatusEntry, ScheduleStatusResponse, ScheduleTrigger, ScheduleWeekday,
3198 SearchEmptyReason, SearchMode, SearchResult, SearchResultChunk, SpaceStatus,
3199 StatusResponse, UpdateReport,
3200 };
3201
3202 struct EnvRestore {
3203 home: Option<OsString>,
3204 config_home: Option<OsString>,
3205 cache_home: Option<OsString>,
3206 visual: Option<OsString>,
3207 editor: Option<OsString>,
3208 }
3209
3210 impl EnvRestore {
3211 fn capture() -> Self {
3212 Self {
3213 home: std::env::var_os("HOME"),
3214 config_home: std::env::var_os("XDG_CONFIG_HOME"),
3215 cache_home: std::env::var_os("XDG_CACHE_HOME"),
3216 visual: std::env::var_os("VISUAL"),
3217 editor: std::env::var_os("EDITOR"),
3218 }
3219 }
3220 }
3221
3222 impl Drop for EnvRestore {
3223 fn drop(&mut self) {
3224 match &self.home {
3225 Some(path) => std::env::set_var("HOME", path),
3226 None => std::env::remove_var("HOME"),
3227 }
3228 match &self.config_home {
3229 Some(path) => std::env::set_var("XDG_CONFIG_HOME", path),
3230 None => std::env::remove_var("XDG_CONFIG_HOME"),
3231 }
3232 match &self.cache_home {
3233 Some(path) => std::env::set_var("XDG_CACHE_HOME", path),
3234 None => std::env::remove_var("XDG_CACHE_HOME"),
3235 }
3236 match &self.visual {
3237 Some(value) => std::env::set_var("VISUAL", value),
3238 None => std::env::remove_var("VISUAL"),
3239 }
3240 match &self.editor {
3241 Some(value) => std::env::set_var("EDITOR", value),
3242 None => std::env::remove_var("EDITOR"),
3243 }
3244 }
3245 }
3246
3247 fn with_isolated_xdg_dirs<T>(run: impl FnOnce() -> T) -> T {
3248 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
3249 let lock = ENV_LOCK.get_or_init(|| Mutex::new(()));
3250 let _guard = lock.lock().expect("lock env mutex");
3251 let _restore = EnvRestore::capture();
3252
3253 let root = tempdir().expect("create temp root");
3254 std::env::set_var("HOME", root.path());
3255 std::env::set_var("XDG_CONFIG_HOME", root.path().join("config-home"));
3256 std::env::set_var("XDG_CACHE_HOME", root.path().join("cache-home"));
3257
3258 run()
3259 }
3260
3261 fn new_collection_dir(root: &Path, name: &str) -> PathBuf {
3262 let path = root.join(name);
3263 fs::create_dir_all(&path).expect("create collection directory");
3264 path
3265 }
3266
3267 #[test]
3268 fn editor_command_resolution_prefers_visual_then_editor_then_vi() {
3269 with_isolated_xdg_dirs(|| {
3270 std::env::set_var("VISUAL", "nvim -f");
3271 std::env::set_var("EDITOR", "vim");
3272 let from_visual = resolve_editor_command().expect("resolve visual");
3273 assert_eq!(from_visual, vec!["nvim".to_string(), "-f".to_string()]);
3274
3275 std::env::remove_var("VISUAL");
3276 let from_editor = resolve_editor_command().expect("resolve editor");
3277 assert_eq!(from_editor, vec!["vim".to_string()]);
3278
3279 std::env::remove_var("EDITOR");
3280 let fallback = resolve_editor_command().expect("resolve fallback");
3281 assert_eq!(fallback, vec!["vi".to_string()]);
3282 });
3283 }
3284
3285 #[test]
3286 fn parse_editor_command_rejects_invalid_shell_words() {
3287 let err = parse_editor_command("'").expect_err("invalid shell words should fail");
3288 assert!(
3289 err.to_string().contains("invalid editor command"),
3290 "unexpected error: {err}"
3291 );
3292 }
3293
3294 #[test]
3295 fn eval_run_report_formats_summary_and_attention_queries() {
3296 let output = format_eval_run_report(&EvalRunReport {
3297 total_cases: 1,
3298 modes: vec![
3299 EvalModeReport {
3300 mode: SearchMode::Keyword,
3301 no_rerank: true,
3302 ndcg_at_10: 1.0,
3303 recall_at_10: 1.0,
3304 mrr_at_10: 1.0,
3305 latency_p50_ms: 2,
3306 latency_p95_ms: 3,
3307 queries: vec![EvalQueryReport {
3308 query: "trait object generic".to_string(),
3309 space: Some("default".to_string()),
3310 collections: vec!["rust".to_string()],
3311 judgments: vec![EvalJudgment {
3312 path: "rust/guides/traits.md".to_string(),
3313 relevance: 1,
3314 }],
3315 returned_paths: vec!["rust/guides/traits.md".to_string()],
3316 matched_paths: vec!["rust/guides/traits.md".to_string()],
3317 first_relevant_rank: Some(1),
3318 elapsed_ms: 2,
3319 }],
3320 },
3321 EvalModeReport {
3322 mode: SearchMode::Deep,
3323 no_rerank: false,
3324 ndcg_at_10: 0.0,
3325 recall_at_10: 0.0,
3326 mrr_at_10: 0.0,
3327 latency_p50_ms: 8,
3328 latency_p95_ms: 12,
3329 queries: vec![EvalQueryReport {
3330 query: "trait object generic".to_string(),
3331 space: Some("default".to_string()),
3332 collections: vec!["rust".to_string()],
3333 judgments: vec![EvalJudgment {
3334 path: "rust/guides/traits.md".to_string(),
3335 relevance: 1,
3336 }],
3337 returned_paths: vec!["rust/overview.md".to_string()],
3338 matched_paths: vec![],
3339 first_relevant_rank: None,
3340 elapsed_ms: 8,
3341 }],
3342 },
3343 ],
3344 failed_modes: vec![kbolt_types::EvalModeFailure {
3345 mode: SearchMode::Semantic,
3346 no_rerank: true,
3347 error: "model not available".to_string(),
3348 }],
3349 });
3350
3351 assert!(output.starts_with("eval complete"));
3352 assert!(output.contains("summary:\n- 1 case(s)"));
3353 assert!(output.contains("modes:\n- keyword"));
3354 assert!(output.contains(" nDCG@10: 1.000"));
3355 assert!(output.contains(" Recall@10: 1.000"));
3356 assert!(output.contains(" MRR@10: 1.000"));
3357 assert!(output.contains(" p50: 2ms"));
3358 assert!(output.contains(" p95: 3ms"));
3359 assert!(output.contains("- deep"));
3360 assert!(output.contains(" nDCG@10: 0.000"));
3361 assert!(output.contains("failed:\n- semantic: model not available"));
3362 assert!(output.contains("attention:\n- deep: trait object generic"));
3363 assert!(output.contains(" first relevant: none"));
3364 assert!(output.contains(" expected: rust/guides/traits.md(rel=1)"));
3365 assert!(output.contains(" returned: rust/overview.md"));
3366 }
3367
3368 #[test]
3369 fn eval_run_report_formats_empty_attention_state() {
3370 let output = format_eval_run_report(&EvalRunReport {
3371 total_cases: 1,
3372 modes: vec![EvalModeReport {
3373 mode: SearchMode::Auto,
3374 no_rerank: false,
3375 ndcg_at_10: 1.0,
3376 recall_at_10: 1.0,
3377 mrr_at_10: 1.0,
3378 latency_p50_ms: 4,
3379 latency_p95_ms: 5,
3380 queries: vec![EvalQueryReport {
3381 query: "trait object generic".to_string(),
3382 space: Some("default".to_string()),
3383 collections: vec!["rust".to_string()],
3384 judgments: vec![EvalJudgment {
3385 path: "rust/guides/traits.md".to_string(),
3386 relevance: 1,
3387 }],
3388 returned_paths: vec!["rust/guides/traits.md".to_string()],
3389 matched_paths: vec!["rust/guides/traits.md".to_string()],
3390 first_relevant_rank: Some(1),
3391 elapsed_ms: 4,
3392 }],
3393 }],
3394 failed_modes: Vec::new(),
3395 });
3396
3397 assert!(output.contains("modes:\n- auto+rerank"));
3398 assert!(output.contains("attention:\n- none"));
3399 assert!(!output.contains("failed:"));
3400 }
3401
3402 #[test]
3403 fn colored_eval_run_report_marks_failed_modes_as_errors() {
3404 let output = format_eval_run_report_color(
3405 &EvalRunReport {
3406 total_cases: 1,
3407 modes: Vec::new(),
3408 failed_modes: vec![kbolt_types::EvalModeFailure {
3409 mode: SearchMode::Semantic,
3410 no_rerank: true,
3411 error: "model not available".to_string(),
3412 }],
3413 },
3414 true,
3415 );
3416
3417 assert!(
3418 output.contains("\x1b[31msemantic: model not available\x1b[0m"),
3419 "failed eval modes should be error-colored:\n{output}"
3420 );
3421 assert!(
3422 output.contains("\x1b[1;36mattention:\x1b[0m\n\x1b[90m-\x1b[0m none"),
3423 "unexpected output:\n{output}"
3424 );
3425 }
3426
3427 #[test]
3428 fn eval_import_report_formats_next_steps() {
3429 let output = format_eval_import_report(&EvalImportReport {
3430 dataset: "scifact".to_string(),
3431 source: "/tmp/scifact-source".to_string(),
3432 output_dir: "/tmp/scifact-bench".to_string(),
3433 corpus_dir: "/tmp/scifact-bench/corpus".to_string(),
3434 manifest_path: "/tmp/scifact-bench/eval.toml".to_string(),
3435 default_space: "bench".to_string(),
3436 collection: "scifact".to_string(),
3437 document_count: 2,
3438 query_count: 2,
3439 judgment_count: 3,
3440 });
3441
3442 assert!(output.contains("benchmark imported: scifact"));
3443 assert!(output.contains("paths:"));
3444 assert!(output.contains("- corpus: /tmp/scifact-bench/corpus"));
3445 assert!(output.contains("contents:"));
3446 assert!(output.contains("- 2 document(s)"));
3447 assert!(output.contains("- 2 query(s)"));
3448 assert!(output.contains("- 3 judgment(s)"));
3449 assert!(output.contains("kbolt space add bench"));
3450 assert!(output.contains("kbolt eval run --file /tmp/scifact-bench/eval.toml"));
3451 }
3452
3453 #[test]
3454 fn models_list_reports_role_binding_readiness() {
3455 with_isolated_xdg_dirs(|| {
3456 let engine = Engine::new(None).expect("create engine");
3457 let adapter = CliAdapter::new(engine);
3458
3459 let output = adapter.models_list().expect("list models");
3460 assert!(
3461 output.starts_with("models:\n"),
3462 "unexpected output: {output}"
3463 );
3464 assert!(
3465 output.contains("- embedder: not configured"),
3466 "unexpected output: {output}"
3467 );
3468 assert!(
3469 output.contains("- reranker: not configured"),
3470 "unexpected output: {output}"
3471 );
3472 assert!(
3473 output.contains("- expander: not configured"),
3474 "unexpected output: {output}"
3475 );
3476 });
3477 }
3478
3479 #[test]
3480 fn models_list_surfaces_not_ready_issue_without_provider_dump() {
3481 let output = format_models_list(&kbolt_types::ModelStatus {
3482 embedder: ModelInfo {
3483 configured: true,
3484 ready: false,
3485 profile: Some("kbolt_local_embed".to_string()),
3486 kind: Some("llama_cpp_server".to_string()),
3487 operation: Some("embedding".to_string()),
3488 model: Some("embeddinggemma".to_string()),
3489 endpoint: Some("http://127.0.0.1:8101".to_string()),
3490 issue: Some("endpoint is unreachable".to_string()),
3491 },
3492 reranker: ModelInfo {
3493 configured: true,
3494 ready: true,
3495 profile: Some("kbolt_local_rerank".to_string()),
3496 kind: Some("llama_cpp_server".to_string()),
3497 operation: Some("reranking".to_string()),
3498 model: Some("qwen3-reranker".to_string()),
3499 endpoint: Some("http://127.0.0.1:8102".to_string()),
3500 issue: None,
3501 },
3502 expander: ModelInfo {
3503 configured: false,
3504 ready: false,
3505 profile: None,
3506 kind: None,
3507 operation: None,
3508 model: None,
3509 endpoint: None,
3510 issue: None,
3511 },
3512 });
3513
3514 assert!(output.contains("- embedder: not ready (embeddinggemma)"));
3515 assert!(output.contains(" issue: endpoint is unreachable"));
3516 assert!(output.contains("- reranker: ready (qwen3-reranker)"));
3517 assert!(output.contains("- expander: not configured"));
3518 assert!(!output.contains("profile="), "unexpected output:\n{output}");
3519 assert!(
3520 !output.contains("endpoint=http"),
3521 "unexpected output:\n{output}"
3522 );
3523 }
3524
3525 #[test]
3526 fn colored_models_list_marks_unavailable_model_states_as_warnings() {
3527 let output = format_models_list_color(
3528 &kbolt_types::ModelStatus {
3529 embedder: ModelInfo {
3530 configured: true,
3531 ready: false,
3532 profile: Some("kbolt_local_embed".to_string()),
3533 kind: Some("llama_cpp_server".to_string()),
3534 operation: Some("embedding".to_string()),
3535 model: Some("embeddinggemma".to_string()),
3536 endpoint: Some("http://127.0.0.1:8101".to_string()),
3537 issue: Some("endpoint is unreachable".to_string()),
3538 },
3539 reranker: ModelInfo {
3540 configured: true,
3541 ready: true,
3542 profile: Some("kbolt_local_rerank".to_string()),
3543 kind: Some("llama_cpp_server".to_string()),
3544 operation: Some("reranking".to_string()),
3545 model: Some("qwen3-reranker".to_string()),
3546 endpoint: Some("http://127.0.0.1:8102".to_string()),
3547 issue: None,
3548 },
3549 expander: ModelInfo {
3550 configured: false,
3551 ready: false,
3552 profile: None,
3553 kind: None,
3554 operation: None,
3555 model: None,
3556 endpoint: None,
3557 issue: None,
3558 },
3559 },
3560 true,
3561 );
3562
3563 assert!(
3564 output.contains("embedder: \x1b[33mnot ready\x1b[0m (embeddinggemma)"),
3565 "unexpected output:\n{output}"
3566 );
3567 assert!(
3568 output.contains("issue: \x1b[33mendpoint is unreachable\x1b[0m"),
3569 "unexpected output:\n{output}"
3570 );
3571 assert!(
3572 output.contains("reranker: ready (qwen3-reranker)"),
3573 "unexpected output:\n{output}"
3574 );
3575 assert!(
3576 output.contains("expander: \x1b[33mnot configured\x1b[0m"),
3577 "unexpected output:\n{output}"
3578 );
3579 }
3580
3581 #[test]
3582 fn doctor_report_success_is_concise() {
3583 let output = format_doctor_report(&DoctorReport {
3584 setup_status: DoctorSetupStatus::Configured,
3585 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
3586 config_dir: Some(PathBuf::from("/tmp/kbolt")),
3587 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
3588 ready: true,
3589 checks: vec![
3590 DoctorCheck {
3591 id: "roles.embedder.bound".to_string(),
3592 scope: "roles.embedder".to_string(),
3593 status: DoctorCheckStatus::Pass,
3594 elapsed_ms: 0,
3595 message: "bound".to_string(),
3596 fix: None,
3597 },
3598 DoctorCheck {
3599 id: "roles.expander.bound".to_string(),
3600 scope: "roles.expander".to_string(),
3601 status: DoctorCheckStatus::Warn,
3602 elapsed_ms: 0,
3603 message: "role is not configured".to_string(),
3604 fix: Some("configure expander".to_string()),
3605 },
3606 ],
3607 });
3608
3609 assert!(
3610 output.contains("kbolt is ready"),
3611 "unexpected output:\n{output}"
3612 );
3613 assert!(output.contains("configured:"));
3614 assert!(output.contains("- embedder"));
3615 assert!(output.contains("not enabled:"));
3616 assert!(output.contains("- expander"));
3617 assert!(
3618 !output.contains("PASS"),
3619 "should not show raw check status in success case"
3620 );
3621 }
3622
3623 #[test]
3624 fn doctor_report_shows_failures_with_fixes() {
3625 let output = format_doctor_report(&DoctorReport {
3626 setup_status: DoctorSetupStatus::Configured,
3627 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
3628 config_dir: Some(PathBuf::from("/tmp/kbolt")),
3629 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
3630 ready: false,
3631 checks: vec![DoctorCheck {
3632 id: "roles.embedder.reachable".to_string(),
3633 scope: "roles.embedder".to_string(),
3634 status: DoctorCheckStatus::Fail,
3635 elapsed_ms: 17,
3636 message: "endpoint is unreachable".to_string(),
3637 fix: Some("Start the embedding server.".to_string()),
3638 }],
3639 });
3640
3641 assert!(
3642 output.contains("kbolt has issues"),
3643 "unexpected output:\n{output}"
3644 );
3645 assert!(output.contains("failures:"));
3646 assert!(output.contains("endpoint is unreachable"));
3647 assert!(output.contains("fix: Start the embedding server."));
3648 }
3649
3650 #[test]
3651 fn doctor_report_missing_config_guides_to_setup() {
3652 let output = format_doctor_report(&DoctorReport {
3653 setup_status: DoctorSetupStatus::ConfigMissing,
3654 config_file: None,
3655 config_dir: None,
3656 cache_dir: None,
3657 ready: false,
3658 checks: vec![],
3659 });
3660
3661 assert!(
3662 output.contains("kbolt is not set up"),
3663 "unexpected output:\n{output}"
3664 );
3665 assert!(output.contains("kbolt setup local"));
3666 }
3667
3668 #[test]
3669 fn doctor_report_invalid_config_groups_failures_and_config_path() {
3670 let output = format_doctor_report(&DoctorReport {
3671 setup_status: DoctorSetupStatus::ConfigInvalid,
3672 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
3673 config_dir: Some(PathBuf::from("/tmp/kbolt")),
3674 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
3675 ready: false,
3676 checks: vec![DoctorCheck {
3677 id: "config.parse".to_string(),
3678 scope: "config".to_string(),
3679 status: DoctorCheckStatus::Fail,
3680 elapsed_ms: 0,
3681 message: "failed to parse config".to_string(),
3682 fix: Some("Fix /tmp/kbolt/index.toml.".to_string()),
3683 }],
3684 });
3685
3686 assert!(
3687 output.starts_with("kbolt configuration is invalid\n"),
3688 "unexpected output:\n{output}"
3689 );
3690 assert!(output.contains("failures:"));
3691 assert!(output.contains("- failed to parse config"));
3692 assert!(output.contains("fix: Fix /tmp/kbolt/index.toml."));
3693 assert!(output.contains("config:"));
3694 assert!(output.contains("- /tmp/kbolt/index.toml"));
3695 }
3696
3697 #[test]
3698 fn doctor_report_not_configured_guides_to_setup() {
3699 let output = format_doctor_report(&DoctorReport {
3700 setup_status: DoctorSetupStatus::NotConfigured,
3701 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
3702 config_dir: Some(PathBuf::from("/tmp/kbolt")),
3703 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
3704 ready: false,
3705 checks: vec![],
3706 });
3707
3708 assert!(
3709 output.starts_with("kbolt is installed but no inference roles are configured\n"),
3710 "unexpected output:\n{output}"
3711 );
3712 assert!(output.contains("next:"));
3713 assert!(output.contains("- kbolt setup local"));
3714 }
3715
3716 #[test]
3717 fn doctor_report_treats_unindexed_storage_as_expected_next_step() {
3718 let output = format_doctor_report(&DoctorReport {
3719 setup_status: DoctorSetupStatus::Configured,
3720 config_file: Some(PathBuf::from("/tmp/kbolt/index.toml")),
3721 config_dir: Some(PathBuf::from("/tmp/kbolt")),
3722 cache_dir: Some(PathBuf::from("/tmp/cache/kbolt")),
3723 ready: true,
3724 checks: vec![
3725 DoctorCheck {
3726 id: "roles.embedder.bound".to_string(),
3727 scope: "roles.embedder".to_string(),
3728 status: DoctorCheckStatus::Pass,
3729 elapsed_ms: 0,
3730 message: "bound".to_string(),
3731 fix: None,
3732 },
3733 DoctorCheck {
3734 id: "storage.sqlite_readable".to_string(),
3735 scope: "storage".to_string(),
3736 status: DoctorCheckStatus::Warn,
3737 elapsed_ms: 0,
3738 message: "index database does not exist yet: /tmp/cache/kbolt/meta.sqlite"
3739 .to_string(),
3740 fix: Some(
3741 "Run `kbolt update` after adding a collection to build the index."
3742 .to_string(),
3743 ),
3744 },
3745 DoctorCheck {
3746 id: "storage.search_indexes_readable".to_string(),
3747 scope: "storage".to_string(),
3748 status: DoctorCheckStatus::Warn,
3749 elapsed_ms: 0,
3750 message: "search index directory does not exist yet: /tmp/cache/kbolt/spaces"
3751 .to_string(),
3752 fix: Some(
3753 "Run `kbolt update` after adding a collection to build search indexes."
3754 .to_string(),
3755 ),
3756 },
3757 ],
3758 });
3759
3760 assert!(
3761 output.contains("kbolt is ready"),
3762 "unexpected output:\n{output}"
3763 );
3764 assert!(output.contains("indexing:"), "unexpected output:\n{output}");
3765 assert!(
3766 output.contains("no collections have been indexed yet"),
3767 "unexpected output:\n{output}"
3768 );
3769 assert!(output.contains("next:"), "unexpected output:\n{output}");
3770 assert!(
3771 output.contains("kbolt collection add /path/to/docs"),
3772 "unexpected output:\n{output}"
3773 );
3774 assert!(
3775 output.contains("or, if collections are already registered: kbolt update"),
3776 "unexpected output:\n{output}"
3777 );
3778 assert!(
3779 !output.contains("warnings:"),
3780 "unexpected output:\n{output}"
3781 );
3782 }
3783
3784 #[test]
3785 fn expected_unindexed_storage_warning_matches_only_storage_warns() {
3786 let warn = DoctorCheck {
3787 id: "storage.sqlite_readable".to_string(),
3788 scope: "storage".to_string(),
3789 status: DoctorCheckStatus::Warn,
3790 elapsed_ms: 0,
3791 message: "missing".to_string(),
3792 fix: None,
3793 };
3794 assert!(is_expected_unindexed_storage_warning(&warn));
3795
3796 let fail = DoctorCheck {
3797 status: DoctorCheckStatus::Fail,
3798 ..warn.clone()
3799 };
3800 assert!(!is_expected_unindexed_storage_warning(&fail));
3801
3802 let other = DoctorCheck {
3803 id: "roles.expander.bound".to_string(),
3804 ..warn
3805 };
3806 assert!(!is_expected_unindexed_storage_warning(&other));
3807 }
3808
3809 #[test]
3810 fn local_report_shows_ready_services_and_hides_internals() {
3811 let output = format_local_report(&LocalReport {
3812 action: LocalAction::Setup,
3813 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
3814 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
3815 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
3816 ready: true,
3817 notes: vec![],
3818 services: vec![
3819 kbolt_types::LocalServiceReport {
3820 name: "embedder".to_string(),
3821 provider: "kbolt_local_embed".to_string(),
3822 enabled: true,
3823 configured: true,
3824 managed: true,
3825 running: true,
3826 ready: true,
3827 model: "embeddinggemma".to_string(),
3828 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
3829 endpoint: "http://127.0.0.1:8101".to_string(),
3830 port: 8101,
3831 pid: Some(42),
3832 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
3833 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
3834 issue: None,
3835 },
3836 kbolt_types::LocalServiceReport {
3837 name: "expander".to_string(),
3838 provider: "kbolt_local_expand".to_string(),
3839 enabled: false,
3840 configured: false,
3841 managed: false,
3842 running: false,
3843 ready: false,
3844 model: "qwen3-1.7b".to_string(),
3845 model_path: PathBuf::from("/tmp/cache/kbolt/models/expander/model.gguf"),
3846 endpoint: "http://127.0.0.1:8103".to_string(),
3847 port: 8103,
3848 pid: None,
3849 pid_file: PathBuf::from("/tmp/cache/kbolt/run/expander.pid"),
3850 log_file: PathBuf::from("/tmp/cache/kbolt/logs/expander.log"),
3851 issue: Some("not configured".to_string()),
3852 },
3853 ],
3854 });
3855
3856 assert!(
3857 output.contains("local setup complete"),
3858 "unexpected output:\n{output}"
3859 );
3860 assert!(output.contains("- embedder (embeddinggemma)"));
3861 assert!(output.contains("not configured:"));
3862 assert!(output.contains("- expander"));
3863 assert!(output.contains("/tmp/kbolt/index.toml"));
3864 assert!(output.contains("kbolt collection add"));
3865 assert!(
3866 !output.contains("pid"),
3867 "should not expose pid in default output"
3868 );
3869 assert!(
3870 !output.contains("log_file"),
3871 "should not expose log_file in default output"
3872 );
3873 assert!(
3874 !output.contains("model_path"),
3875 "should not expose model_path in default output"
3876 );
3877 }
3878
3879 #[test]
3880 fn local_report_surfaces_notes() {
3881 let output = format_local_report(&LocalReport {
3882 action: LocalAction::Setup,
3883 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
3884 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
3885 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
3886 ready: true,
3887 notes: vec![
3888 "moved incompatible old config to /tmp/index.toml.invalid.bak".to_string(),
3889 "started embedder on http://127.0.0.1:8101".to_string(),
3890 ],
3891 services: vec![],
3892 });
3893
3894 assert!(output.contains("notes:"), "unexpected output:\n{output}");
3895 assert!(
3896 output.contains("moved incompatible old config"),
3897 "unexpected output:\n{output}"
3898 );
3899 assert!(
3900 output.contains("started embedder on http://127.0.0.1:8101"),
3901 "unexpected output:\n{output}"
3902 );
3903 }
3904
3905 #[test]
3906 fn local_report_shows_issues_when_not_ready() {
3907 let output = format_local_report(&LocalReport {
3908 action: LocalAction::Setup,
3909 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
3910 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
3911 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
3912 ready: false,
3913 notes: vec![],
3914 services: vec![kbolt_types::LocalServiceReport {
3915 name: "embedder".to_string(),
3916 provider: "kbolt_local_embed".to_string(),
3917 enabled: true,
3918 configured: true,
3919 managed: true,
3920 running: true,
3921 ready: false,
3922 model: "embeddinggemma".to_string(),
3923 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
3924 endpoint: "http://127.0.0.1:8101".to_string(),
3925 port: 8101,
3926 pid: Some(42),
3927 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
3928 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
3929 issue: Some("service is not ready".to_string()),
3930 }],
3931 });
3932
3933 assert!(
3934 output.contains("(not ready)"),
3935 "unexpected output:\n{output}"
3936 );
3937 assert!(output.contains("issues:"));
3938 assert!(output.contains("embedder: service is not ready"));
3939 assert!(output.contains("log: /tmp/cache/kbolt/logs/embedder.log"));
3940 }
3941
3942 #[test]
3943 fn local_status_report_uses_server_summary_sections() {
3944 let output = format_local_report(&LocalReport {
3945 action: LocalAction::Status,
3946 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
3947 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
3948 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
3949 ready: false,
3950 notes: vec![],
3951 services: vec![
3952 kbolt_types::LocalServiceReport {
3953 name: "embedder".to_string(),
3954 provider: "kbolt_local_embed".to_string(),
3955 enabled: true,
3956 configured: true,
3957 managed: true,
3958 running: true,
3959 ready: true,
3960 model: "embeddinggemma".to_string(),
3961 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
3962 endpoint: "http://127.0.0.1:8101".to_string(),
3963 port: 8101,
3964 pid: Some(42),
3965 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
3966 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
3967 issue: None,
3968 },
3969 kbolt_types::LocalServiceReport {
3970 name: "reranker".to_string(),
3971 provider: "kbolt_local_rerank".to_string(),
3972 enabled: true,
3973 configured: true,
3974 managed: true,
3975 running: false,
3976 ready: false,
3977 model: "qwen3-reranker".to_string(),
3978 model_path: PathBuf::from("/tmp/cache/kbolt/models/reranker/model.gguf"),
3979 endpoint: "http://127.0.0.1:8102".to_string(),
3980 port: 8102,
3981 pid: None,
3982 pid_file: PathBuf::from("/tmp/cache/kbolt/run/reranker.pid"),
3983 log_file: PathBuf::from("/tmp/cache/kbolt/logs/reranker.log"),
3984 issue: Some("service is not ready".to_string()),
3985 },
3986 ],
3987 });
3988
3989 assert!(
3990 output.starts_with("local servers (not ready)\n"),
3991 "unexpected output:\n{output}"
3992 );
3993 assert!(output.contains("ready:"));
3994 assert!(output.contains("- embedder (embeddinggemma)"));
3995 assert!(output.contains("issues:"));
3996 assert!(output.contains("- reranker: service is not ready"));
3997 assert!(output.contains("config:"));
3998 }
3999
4000 #[test]
4001 fn local_stop_report_treats_stopped_services_as_expected() {
4002 let output = format_local_report(&LocalReport {
4003 action: LocalAction::Stop,
4004 config_file: PathBuf::from("/tmp/kbolt/index.toml"),
4005 cache_dir: PathBuf::from("/tmp/cache/kbolt"),
4006 llama_server_path: Some(PathBuf::from("/opt/homebrew/bin/llama-server")),
4007 ready: false,
4008 notes: vec![
4009 "stopped embedder".to_string(),
4010 "stopped reranker".to_string(),
4011 ],
4012 services: vec![
4013 kbolt_types::LocalServiceReport {
4014 name: "embedder".to_string(),
4015 provider: "kbolt_local_embed".to_string(),
4016 enabled: true,
4017 configured: true,
4018 managed: false,
4019 running: false,
4020 ready: false,
4021 model: "embeddinggemma".to_string(),
4022 model_path: PathBuf::from("/tmp/cache/kbolt/models/embedder/model.gguf"),
4023 endpoint: "http://127.0.0.1:8101".to_string(),
4024 port: 8101,
4025 pid: None,
4026 pid_file: PathBuf::from("/tmp/cache/kbolt/run/embedder.pid"),
4027 log_file: PathBuf::from("/tmp/cache/kbolt/logs/embedder.log"),
4028 issue: Some("service is not ready".to_string()),
4029 },
4030 kbolt_types::LocalServiceReport {
4031 name: "reranker".to_string(),
4032 provider: "kbolt_local_rerank".to_string(),
4033 enabled: true,
4034 configured: true,
4035 managed: false,
4036 running: false,
4037 ready: false,
4038 model: "qwen3-reranker".to_string(),
4039 model_path: PathBuf::from("/tmp/cache/kbolt/models/reranker/model.gguf"),
4040 endpoint: "http://127.0.0.1:8102".to_string(),
4041 port: 8102,
4042 pid: None,
4043 pid_file: PathBuf::from("/tmp/cache/kbolt/run/reranker.pid"),
4044 log_file: PathBuf::from("/tmp/cache/kbolt/logs/reranker.log"),
4045 issue: Some("service is not ready".to_string()),
4046 },
4047 kbolt_types::LocalServiceReport {
4048 name: "expander".to_string(),
4049 provider: "kbolt_local_expand".to_string(),
4050 enabled: false,
4051 configured: false,
4052 managed: false,
4053 running: false,
4054 ready: false,
4055 model: "qwen3-1.7b".to_string(),
4056 model_path: PathBuf::from("/tmp/cache/kbolt/models/expander/model.gguf"),
4057 endpoint: "http://127.0.0.1:8103".to_string(),
4058 port: 8103,
4059 pid: None,
4060 pid_file: PathBuf::from("/tmp/cache/kbolt/run/expander.pid"),
4061 log_file: PathBuf::from("/tmp/cache/kbolt/logs/expander.log"),
4062 issue: Some("service is not configured".to_string()),
4063 },
4064 ],
4065 });
4066
4067 assert!(
4068 output.starts_with("local servers stopped\n"),
4069 "unexpected output:\n{output}"
4070 );
4071 assert!(
4072 !output.contains("(not ready)"),
4073 "unexpected output:\n{output}"
4074 );
4075 assert!(!output.contains("issues:"), "unexpected output:\n{output}");
4076 assert!(
4077 !output.contains("not configured:"),
4078 "unexpected output:\n{output}"
4079 );
4080 assert!(
4081 output.contains("stopped embedder"),
4082 "unexpected output:\n{output}"
4083 );
4084 assert!(
4085 output.contains("stopped reranker"),
4086 "unexpected output:\n{output}"
4087 );
4088 }
4089
4090 #[test]
4091 fn truncate_snippet_preserves_short_text() {
4092 assert_eq!(
4093 truncate_snippet("line one\nline two", 4),
4094 "line one\nline two"
4095 );
4096 }
4097
4098 #[test]
4099 fn truncate_snippet_truncates_long_text() {
4100 let text = "one\ntwo\nthree\nfour\nfive\nsix";
4101 let result = truncate_snippet(text, 3);
4102 assert!(result.contains("one\ntwo\nthree\n"), "unexpected: {result}");
4103 assert!(result.contains("(+3 more lines)"), "unexpected: {result}");
4104 }
4105
4106 #[test]
4107 fn search_rejects_conflicting_mode_flags() {
4108 with_isolated_xdg_dirs(|| {
4109 let adapter = CliAdapter::new(Engine::new(None).expect("create engine"));
4110
4111 let err = adapter
4112 .search(CliSearchOptions {
4113 space: None,
4114 query: "alpha",
4115 collections: &[],
4116 limit: 10,
4117 min_score: 0.0,
4118 deep: true,
4119 keyword: true,
4120 semantic: false,
4121 rerank: false,
4122 no_rerank: false,
4123 debug: false,
4124 })
4125 .expect_err("conflicting search flags should fail");
4126 assert!(
4127 err.to_string()
4128 .contains("only one of --deep, --keyword, or --semantic"),
4129 "unexpected error: {err}"
4130 );
4131 });
4132 }
4133
4134 #[test]
4135 fn empty_index_hint_fresh_install_suggests_scoped_add_collection() {
4136 with_isolated_xdg_dirs(|| {
4137 let adapter = CliAdapter::new(Engine::new(None).expect("create engine"));
4138 let hint = adapter
4139 .empty_index_hint(None, &[], None)
4140 .expect("hint should fire on a fresh home");
4141 assert!(
4142 hint.contains("no collections registered yet"),
4143 "unexpected hint: {hint}"
4144 );
4145 assert!(
4146 hint.contains("next:\n- "),
4147 "hint should use sectioned next steps: {hint}"
4148 );
4149 assert!(
4150 hint.contains("kbolt --space default collection add /path/to/docs"),
4151 "should suggest --space default on fresh install: {hint}"
4152 );
4153 });
4154 }
4155
4156 #[test]
4157 fn empty_index_hint_is_silent_when_any_collection_has_content() {
4158 with_isolated_xdg_dirs(|| {
4159 let root = tempdir().expect("create temp root");
4160 let engine = Engine::new(None).expect("create engine");
4161 let coll_path = new_collection_dir(root.path(), "notes");
4162 fs::write(coll_path.join("a.md"), "hello world\n").expect("write file");
4163 engine
4164 .add_collection(AddCollectionRequest {
4165 path: coll_path,
4166 space: Some("default".to_string()),
4167 name: Some("notes".to_string()),
4168 description: None,
4169 extensions: None,
4170 no_index: true,
4171 })
4172 .expect("add collection");
4173
4174 let adapter = CliAdapter::new(engine);
4175 adapter
4176 .update(Some("default"), &[], true, false, false)
4177 .expect("run update");
4178
4179 let hint = adapter.empty_index_hint(None, &[], None);
4180 assert!(
4181 hint.is_none(),
4182 "expected silent when content exists, got: {hint:?}"
4183 );
4184 });
4185 }
4186
4187 #[test]
4188 fn empty_index_hint_is_silent_on_mixed_unscoped_search() {
4189 with_isolated_xdg_dirs(|| {
4190 let root = tempdir().expect("create temp root");
4191 let engine = Engine::new(None).expect("create engine");
4192
4193 let hot = new_collection_dir(root.path(), "hot");
4194 fs::write(hot.join("a.md"), "content\n").expect("write file");
4195 engine
4196 .add_collection(AddCollectionRequest {
4197 path: hot,
4198 space: Some("default".to_string()),
4199 name: Some("hot".to_string()),
4200 description: None,
4201 extensions: None,
4202 no_index: true,
4203 })
4204 .expect("add hot");
4205
4206 let cold = new_collection_dir(root.path(), "cold");
4207 engine
4208 .add_collection(AddCollectionRequest {
4209 path: cold,
4210 space: Some("default".to_string()),
4211 name: Some("cold".to_string()),
4212 description: None,
4213 extensions: None,
4214 no_index: true,
4215 })
4216 .expect("add cold");
4217
4218 let adapter = CliAdapter::new(engine);
4219 adapter
4220 .update(Some("default"), &[], true, false, false)
4221 .expect("run update");
4222
4223 let hint = adapter.empty_index_hint(None, &[], None);
4224 assert!(
4225 hint.is_none(),
4226 "expected silent on mixed unscoped, got: {hint:?}"
4227 );
4228 });
4229 }
4230
4231 #[test]
4232 fn empty_index_hint_scoped_to_empty_collection_suggests_update() {
4233 with_isolated_xdg_dirs(|| {
4234 let root = tempdir().expect("create temp root");
4235 let engine = Engine::new(None).expect("create engine");
4236
4237 let hot = new_collection_dir(root.path(), "hot");
4238 fs::write(hot.join("a.md"), "content\n").expect("write file");
4239 engine
4240 .add_collection(AddCollectionRequest {
4241 path: hot,
4242 space: Some("default".to_string()),
4243 name: Some("hot".to_string()),
4244 description: None,
4245 extensions: None,
4246 no_index: true,
4247 })
4248 .expect("add hot");
4249
4250 let cold = new_collection_dir(root.path(), "cold");
4251 engine
4252 .add_collection(AddCollectionRequest {
4253 path: cold,
4254 space: Some("default".to_string()),
4255 name: Some("cold".to_string()),
4256 description: None,
4257 extensions: None,
4258 no_index: true,
4259 })
4260 .expect("add cold");
4261
4262 let adapter = CliAdapter::new(engine);
4263 adapter
4264 .update(Some("default"), &[], true, false, false)
4265 .expect("run update");
4266
4267 let hint = adapter
4268 .empty_index_hint(
4269 None,
4270 &["cold".to_string()],
4271 Some(&SearchEmptyReason::UnindexedScope),
4272 )
4273 .expect("hint should fire when scoped to an empty collection");
4274
4275 assert!(
4276 hint.contains("nothing indexed for this scope"),
4277 "unexpected hint: {hint}"
4278 );
4279 assert!(
4280 hint.contains("next:\n- "),
4281 "hint should use sectioned next steps: {hint}"
4282 );
4283 assert!(
4284 hint.contains("kbolt --space default update --collection cold"),
4285 "should point at update with the actual space: {hint}"
4286 );
4287 });
4288 }
4289
4290 #[test]
4291 fn empty_index_hint_shell_quotes_names_with_whitespace() {
4292 with_isolated_xdg_dirs(|| {
4293 let root = tempdir().expect("create temp root");
4294 let engine = Engine::new(None).expect("create engine");
4295
4296 let cold = new_collection_dir(root.path(), "cold");
4297 engine
4298 .add_collection(AddCollectionRequest {
4299 path: cold,
4300 space: Some("default".to_string()),
4301 name: Some("cold docs".to_string()),
4302 description: None,
4303 extensions: None,
4304 no_index: true,
4305 })
4306 .expect("add cold docs");
4307
4308 let adapter = CliAdapter::new(engine);
4309
4310 let hint = adapter
4311 .empty_index_hint(
4312 None,
4313 &["cold docs".to_string()],
4314 Some(&SearchEmptyReason::UnindexedScope),
4315 )
4316 .expect("hint should fire");
4317
4318 assert!(
4319 hint.contains("kbolt --space default update --collection 'cold docs'"),
4320 "collection name with whitespace must be single-quoted in the hint: {hint}"
4321 );
4322 });
4323 }
4324
4325 #[test]
4326 fn shell_quote_arg_leaves_safe_tokens_alone_and_escapes_risky_ones() {
4327 assert_eq!(shell_quote_arg("default"), "default");
4328 assert_eq!(shell_quote_arg("work-notes"), "work-notes");
4329 assert_eq!(shell_quote_arg("./path/to/docs"), "./path/to/docs");
4330 assert_eq!(shell_quote_arg("cold docs"), "'cold docs'");
4331 assert_eq!(shell_quote_arg(""), "''");
4332 assert_eq!(shell_quote_arg("it's fine"), "'it'\\''s fine'");
4333 }
4334
4335 #[test]
4336 fn resolve_no_rerank_for_mode_matches_cli_contract() {
4337 assert!(resolve_no_rerank_for_mode(SearchMode::Auto, false, false));
4338 assert!(!resolve_no_rerank_for_mode(SearchMode::Auto, true, false));
4339 assert!(!resolve_no_rerank_for_mode(SearchMode::Deep, false, false));
4340 assert!(resolve_no_rerank_for_mode(SearchMode::Deep, false, true));
4341 assert!(resolve_no_rerank_for_mode(SearchMode::Keyword, true, false));
4342 assert!(resolve_no_rerank_for_mode(
4343 SearchMode::Semantic,
4344 true,
4345 false
4346 ));
4347 }
4348
4349 #[test]
4350 fn search_reports_requested_and_effective_mode_for_auto_keyword_fallback() {
4351 with_isolated_xdg_dirs(|| {
4352 let root = tempdir().expect("create collection root");
4353 let engine = Engine::new(None).expect("create engine");
4354 engine.add_space("work", None).expect("add work");
4355
4356 let work_path = new_collection_dir(root.path(), "work-api");
4357 engine
4358 .add_collection(AddCollectionRequest {
4359 path: work_path.clone(),
4360 space: Some("work".to_string()),
4361 name: Some("api".to_string()),
4362 description: None,
4363 extensions: None,
4364 no_index: true,
4365 })
4366 .expect("add collection");
4367 fs::write(work_path.join("a.md"), "fallback token\n").expect("write file");
4368
4369 let adapter = CliAdapter::new(engine);
4370 adapter
4371 .update(Some("work"), &["api".to_string()], true, false, false)
4372 .expect("run update");
4373
4374 let output = adapter
4375 .search(CliSearchOptions {
4376 space: Some("work"),
4377 query: "fallback",
4378 collections: &["api".to_string()],
4379 limit: 5,
4380 min_score: 0.0,
4381 deep: false,
4382 keyword: false,
4383 semantic: false,
4384 rerank: false,
4385 no_rerank: false,
4386 debug: true,
4387 })
4388 .expect("run auto search");
4389
4390 assert!(
4391 output.starts_with("debug:\n"),
4392 "unexpected output: {output}"
4393 );
4394 assert!(
4395 output.contains("mode: auto -> keyword"),
4396 "unexpected output: {output}"
4397 );
4398 assert!(
4399 output.contains("pipeline: keyword"),
4400 "unexpected output: {output}"
4401 );
4402 assert!(
4403 output.contains("result count: 1"),
4404 "unexpected output: {output}"
4405 );
4406 assert!(
4407 output.contains("note: dense retrieval unavailable: not configured"),
4408 "unexpected output: {output}"
4409 );
4410 });
4411 }
4412
4413 #[test]
4414 fn search_normal_output_includes_subtle_capability_fallbacks() {
4415 with_isolated_xdg_dirs(|| {
4416 let root = tempdir().expect("create collection root");
4417 let engine = Engine::new(None).expect("create engine");
4418 engine.add_space("work", None).expect("add work");
4419
4420 let work_path = new_collection_dir(root.path(), "work-api");
4421 engine
4422 .add_collection(AddCollectionRequest {
4423 path: work_path.clone(),
4424 space: Some("work".to_string()),
4425 name: Some("api".to_string()),
4426 description: None,
4427 extensions: None,
4428 no_index: true,
4429 })
4430 .expect("add collection");
4431 fs::write(work_path.join("a.md"), "fallback token\n").expect("write file");
4432
4433 let adapter = CliAdapter::new(engine);
4434 adapter
4435 .update(Some("work"), &["api".to_string()], true, false, false)
4436 .expect("run update");
4437
4438 let output = adapter
4439 .search(CliSearchOptions {
4440 space: Some("work"),
4441 query: "fallback",
4442 collections: &["api".to_string()],
4443 limit: 5,
4444 min_score: 0.0,
4445 deep: false,
4446 keyword: false,
4447 semantic: false,
4448 rerank: true,
4449 no_rerank: false,
4450 debug: false,
4451 })
4452 .expect("run auto search");
4453
4454 assert!(
4455 output.contains("keyword only (dense unavailable)"),
4456 "unexpected output: {output}"
4457 );
4458 assert!(
4459 output.contains("rerank skipped"),
4460 "unexpected output: {output}"
4461 );
4462 assert!(
4463 !output.contains("note:"),
4464 "normal output should keep fallback messaging subtle: {output}"
4465 );
4466
4467 let colored_output = adapter
4468 .with_color(true)
4469 .search(CliSearchOptions {
4470 space: Some("work"),
4471 query: "fallback",
4472 collections: &["api".to_string()],
4473 limit: 5,
4474 min_score: 0.0,
4475 deep: false,
4476 keyword: false,
4477 semantic: false,
4478 rerank: true,
4479 no_rerank: false,
4480 debug: false,
4481 })
4482 .expect("run colored auto search");
4483
4484 assert!(
4485 colored_output.contains("\x1b[33mkeyword only (dense unavailable)\x1b[0m"),
4486 "degraded dense fallback should use warning color:\n{colored_output}"
4487 );
4488 assert!(
4489 colored_output.contains("\x1b[33mrerank skipped\x1b[0m"),
4490 "skipped rerank notice should use warning color:\n{colored_output}"
4491 );
4492 });
4493 }
4494
4495 #[test]
4496 fn search_result_paths_include_space_context() {
4497 assert_eq!(
4498 format_search_result_path("work", "api/guide.md"),
4499 "work/api/guide.md"
4500 );
4501 }
4502
4503 fn make_search_result(docid: &str, path: &str, score: f32, text: &str) -> SearchResult {
4504 SearchResult {
4505 docid: docid.to_string(),
4506 chunk: SearchResultChunk {
4507 locator: format!("{docid}@1"),
4508 ordinal: 1,
4509 },
4510 path: path.to_string(),
4511 title: format!("title for {path}"),
4512 space: "work".to_string(),
4513 collection: "docs".to_string(),
4514 heading: None,
4515 text: text.to_string(),
4516 score,
4517 signals: None,
4518 }
4519 }
4520
4521 fn make_search_result_in_space(
4522 docid: &str,
4523 space: &str,
4524 path: &str,
4525 score: f32,
4526 text: &str,
4527 ) -> SearchResult {
4528 SearchResult {
4529 docid: docid.to_string(),
4530 chunk: SearchResultChunk {
4531 locator: format!("{docid}@1"),
4532 ordinal: 1,
4533 },
4534 path: path.to_string(),
4535 title: format!("title for {path}"),
4536 space: space.to_string(),
4537 collection: "docs".to_string(),
4538 heading: None,
4539 text: text.to_string(),
4540 score,
4541 signals: None,
4542 }
4543 }
4544
4545 #[test]
4546 fn additional_match_label_uses_heading_or_chunk_ordinal() {
4547 let mut result = make_search_result("#doc-a", "docs/a.md", 0.95, "alpha");
4548
4549 result.heading = Some("Guide > Setup".to_string());
4550 result.chunk.ordinal = 2;
4551 assert_eq!(
4552 format_additional_match_label(&result),
4553 "Guide > Setup (chunk 2)"
4554 );
4555
4556 result.heading = None;
4557 assert_eq!(format_additional_match_label(&result), "chunk 2");
4558 }
4559
4560 #[test]
4561 fn search_chunk_read_command_uses_compact_docid_locator() {
4562 let locator = format_compact_chunk_locator("#96eafc", 2);
4563 assert_eq!(
4564 format_chunk_read_command("uiux", &locator),
4565 "kbolt --space uiux get '#96eafc@2'"
4566 );
4567 }
4568
4569 #[test]
4570 fn paint_only_emits_ansi_when_enabled() {
4571 assert_eq!(paint(false, CliStyle::Identity, "path"), "path");
4572 assert_eq!(paint(true, CliStyle::Label, "path:"), "path:");
4573 assert_eq!(
4574 paint(true, CliStyle::Identity, "path"),
4575 "\x1b[34mpath\x1b[0m"
4576 );
4577 assert_eq!(paint_identity(true, "#409380"), "\x1b[34m#409380\x1b[0m");
4578 assert_eq!(
4579 paint_action(true, "kbolt get '#409380@1'"),
4580 "\x1b[32mkbolt get '#409380@1'\x1b[0m"
4581 );
4582 assert_eq!(paint_warning(true, "stale"), "\x1b[33mstale\x1b[0m");
4583 assert_eq!(paint_error(true, "failed"), "\x1b[31mfailed\x1b[0m");
4584 }
4585
4586 #[test]
4587 fn palette_avoids_dim_and_black_foreground_styles() {
4588 for style in CliStyle::ALL {
4589 if let Some(code) = ansi_code(style) {
4590 assert!(
4591 !code.split(';').any(|part| part == "2" || part == "30"),
4592 "palette style {code} must not use dim or black foreground"
4593 );
4594 }
4595 }
4596 }
4597
4598 #[test]
4599 fn color_multi_get_keeps_document_body_plain() {
4600 let output = format_multi_get_response_color(
4601 &MultiGetResponse {
4602 items: vec![MultiGetItem::Document(DocumentResponse {
4603 docid: "#409380".to_string(),
4604 title: "Guide".to_string(),
4605 path: "api/guide.md".to_string(),
4606 space: "work".to_string(),
4607 collection: "api".to_string(),
4608 content: "plain body line\n- copyable bullet".to_string(),
4609 stale: false,
4610 total_lines: 2,
4611 returned_lines: 2,
4612 })],
4613 omitted: Vec::new(),
4614 resolved_count: 1,
4615 warnings: Vec::new(),
4616 },
4617 true,
4618 );
4619
4620 assert!(output.contains("\x1b[1;36mitems:\x1b[0m"));
4621 assert!(output.lines().any(|line| line == "plain body line"));
4622 assert!(output.lines().any(|line| line == "- copyable bullet"));
4623 }
4624
4625 #[test]
4626 fn color_multi_get_warnings_are_warning_colored() {
4627 let output = format_multi_get_response_color(
4628 &MultiGetResponse {
4629 items: Vec::new(),
4630 omitted: Vec::new(),
4631 resolved_count: 0,
4632 warnings: vec!["item not found: api/missing.md".to_string()],
4633 },
4634 true,
4635 );
4636
4637 assert!(output.contains("\x1b[33mitem not found: api/missing.md\x1b[0m"));
4638 }
4639
4640 #[test]
4641 fn color_multi_get_omitted_items_are_warning_colored() {
4642 let output = format_multi_get_response_color(
4643 &MultiGetResponse {
4644 items: Vec::new(),
4645 omitted: vec![OmittedItem {
4646 kind: MultiGetItemKind::Document,
4647 locator: "#large1".to_string(),
4648 path: "api/large.md".to_string(),
4649 docid: "#large1".to_string(),
4650 space: "work".to_string(),
4651 size_bytes: 8192,
4652 reason: OmitReason::MaxBytes,
4653 }],
4654 resolved_count: 1,
4655 warnings: Vec::new(),
4656 },
4657 true,
4658 );
4659
4660 assert!(output.contains("\x1b[33mapi/large.md (8.0 KB, size limit)\x1b[0m"));
4661 }
4662
4663 #[test]
4664 fn search_chunk_locator_extends_short_docid_collisions() {
4665 with_isolated_xdg_dirs(|| {
4666 let root = tempdir().expect("create collection root");
4667 let engine = Engine::new(None).expect("create engine");
4668 engine.add_space("work", None).expect("add work");
4669
4670 let docs_path = new_collection_dir(root.path(), "work-docs");
4671 engine
4672 .add_collection(AddCollectionRequest {
4673 path: docs_path.clone(),
4674 space: Some("work".to_string()),
4675 name: Some("docs".to_string()),
4676 description: None,
4677 extensions: None,
4678 no_index: true,
4679 })
4680 .expect("add collection");
4681
4682 fs::write(docs_path.join("guide.md"), "# Guide\n\nalpha target\n")
4683 .expect("write guide");
4684 let adapter = CliAdapter::new(engine);
4685 adapter
4686 .update(Some("work"), &["docs".to_string()], true, false, false)
4687 .expect("run update");
4688
4689 let space_row = adapter
4690 .engine
4691 .storage()
4692 .get_space("work")
4693 .expect("get space");
4694 let collection_row = adapter
4695 .engine
4696 .storage()
4697 .get_collection(space_row.id, "docs")
4698 .expect("get collection");
4699 let document = adapter
4700 .engine
4701 .storage()
4702 .get_document_by_path(collection_row.id, "guide.md")
4703 .expect("get document")
4704 .expect("document exists");
4705 let seventh = if &document.hash[6..7] == "a" {
4706 "b"
4707 } else {
4708 "a"
4709 };
4710 let colliding_hash = format!(
4711 "{}{}{}",
4712 &document.hash[..6],
4713 seventh,
4714 "0".repeat(document.hash.len().saturating_sub(7))
4715 );
4716 adapter
4717 .engine
4718 .storage()
4719 .upsert_document(
4720 collection_row.id,
4721 "collision.md",
4722 Some("collision.md"),
4723 &colliding_hash,
4724 "2026-05-23T12:00:00Z",
4725 )
4726 .expect("insert colliding document");
4727
4728 let chunk = adapter
4729 .engine
4730 .storage()
4731 .get_chunks_for_document(document.id)
4732 .expect("get chunks")
4733 .into_iter()
4734 .next()
4735 .expect("chunk exists");
4736 let ordinal = usize::try_from(chunk.seq + 1).expect("positive chunk ordinal");
4737 let result = SearchResult {
4738 docid: format!("#{}", &document.hash[..6]),
4739 chunk: SearchResultChunk {
4740 locator: format!("#{}@{ordinal}", document.hash),
4741 ordinal,
4742 },
4743 path: "guide.md".to_string(),
4744 title: "Guide".to_string(),
4745 space: "work".to_string(),
4746 collection: "docs".to_string(),
4747 heading: None,
4748 text: "alpha target".to_string(),
4749 score: 1.0,
4750 signals: None,
4751 };
4752
4753 let locator = adapter
4754 .search_chunk_locator(&result)
4755 .expect("compact locator");
4756 assert!(
4757 locator.starts_with(&format!("#{}", &document.hash[..7])),
4758 "locator should extend the colliding short prefix: {locator}"
4759 );
4760
4761 let chunk_output = adapter
4762 .get(CliGetOptions {
4763 space: Some("work"),
4764 identifier: &locator,
4765 offset: None,
4766 limit: None,
4767 })
4768 .expect("rendered compact locator should be executable");
4769 assert!(
4770 chunk_output.contains("alpha target"),
4771 "unexpected chunk output:\n{chunk_output}"
4772 );
4773 assert!(
4774 chunk_output.contains(&locator),
4775 "chunk output should keep the compact handle from the read flow:\n{chunk_output}"
4776 );
4777
4778 let full_locator = format!("#{}@{ordinal}", document.hash);
4779 let full_locator_output = adapter
4780 .get(CliGetOptions {
4781 space: Some("work"),
4782 identifier: &full_locator,
4783 offset: None,
4784 limit: None,
4785 })
4786 .expect("full chunk locator should be executable");
4787 assert!(
4788 full_locator_output.contains(&locator),
4789 "human chunk output should compact full locators:\n{full_locator_output}"
4790 );
4791 assert!(
4792 !full_locator_output.contains(&document.hash),
4793 "human chunk output should not expose the full document hash:\n{full_locator_output}"
4794 );
4795
4796 let multi_get_output = adapter
4797 .multi_get(
4798 Some("work"),
4799 std::slice::from_ref(&full_locator),
4800 10,
4801 51_200,
4802 )
4803 .expect("multi-get should accept full chunk locators");
4804 assert!(
4805 multi_get_output.contains(&locator),
4806 "multi-get chunk output should use the same compact handle:\n{multi_get_output}"
4807 );
4808 assert!(
4809 !multi_get_output.contains(&document.hash),
4810 "multi-get chunk output should not expose the full document hash:\n{multi_get_output}"
4811 );
4812
4813 let omitted_locators = vec![full_locator.clone(), full_locator.clone()];
4814 let omitted_output = adapter
4815 .multi_get(Some("work"), &omitted_locators, 1, 51_200)
4816 .expect("multi-get should compact omitted chunk locators");
4817 assert!(
4818 omitted_output.contains(&locator),
4819 "omitted chunk output should use the same compact handle:\n{omitted_output}"
4820 );
4821 assert!(
4822 !omitted_output.contains(&document.hash),
4823 "omitted chunk output should not expose the full document hash:\n{omitted_output}"
4824 );
4825 });
4826 }
4827
4828 #[test]
4829 fn group_search_results_uses_first_chunk_per_document_and_counts_duplicates() {
4830 let results = vec![
4831 make_search_result("doc-a", "docs/a.md", 0.95, "alpha first"),
4832 make_search_result("doc-a", "docs/a.md", 0.91, "alpha second"),
4833 make_search_result("doc-b", "docs/b.md", 0.88, "beta first"),
4834 make_search_result("doc-c", "docs/c.md", 0.83, "gamma first"),
4835 make_search_result("doc-b", "docs/b.md", 0.81, "beta second"),
4836 ];
4837
4838 let grouped = group_search_results(&results, 10);
4839
4840 assert_eq!(grouped.len(), 3);
4841 assert_eq!(grouped[0].primary.docid, "doc-a");
4842 assert_eq!(grouped[0].primary.text, "alpha first");
4843 assert_eq!(grouped[0].additional_matches.len(), 1);
4844 assert_eq!(grouped[1].primary.docid, "doc-b");
4845 assert_eq!(grouped[1].primary.text, "beta first");
4846 assert_eq!(grouped[1].additional_matches.len(), 1);
4847 assert_eq!(grouped[2].primary.docid, "doc-c");
4848 assert_eq!(grouped[2].additional_matches.len(), 0);
4849 }
4850
4851 #[test]
4852 fn group_search_results_respects_group_limit_but_counts_visible_duplicates() {
4853 let results = vec![
4854 make_search_result("doc-a", "docs/a.md", 0.95, "alpha first"),
4855 make_search_result("doc-b", "docs/b.md", 0.90, "beta first"),
4856 make_search_result("doc-c", "docs/c.md", 0.85, "gamma first"),
4857 make_search_result("doc-b", "docs/b.md", 0.82, "beta second"),
4858 make_search_result("doc-a", "docs/a.md", 0.80, "alpha second"),
4859 ];
4860
4861 let grouped = group_search_results(&results, 2);
4862
4863 assert_eq!(grouped.len(), 2);
4864 assert_eq!(grouped[0].primary.docid, "doc-a");
4865 assert_eq!(grouped[0].additional_matches.len(), 1);
4866 assert_eq!(grouped[1].primary.docid, "doc-b");
4867 assert_eq!(grouped[1].additional_matches.len(), 1);
4868 assert!(
4869 grouped.iter().all(|item| item.primary.docid != "doc-c"),
4870 "unexpected grouped results: {:?}",
4871 grouped
4872 .iter()
4873 .map(|item| item.primary.docid.as_str())
4874 .collect::<Vec<_>>()
4875 );
4876 }
4877
4878 #[test]
4879 fn group_search_results_does_not_merge_short_docid_collisions_across_spaces() {
4880 let results = vec![
4881 make_search_result_in_space(
4882 "#abc123",
4883 "default",
4884 "docs/guide.md",
4885 0.95,
4886 "default guide",
4887 ),
4888 make_search_result_in_space("#abc123", "work", "docs/guide.md", 0.90, "work guide"),
4889 ];
4890
4891 let grouped = group_search_results(&results, 10);
4892
4893 assert_eq!(grouped.len(), 2);
4894 assert_eq!(grouped[0].primary.space, "default");
4895 assert_eq!(grouped[0].primary.path, "docs/guide.md");
4896 assert_eq!(grouped[0].additional_matches.len(), 0);
4897 assert_eq!(grouped[1].primary.space, "work");
4898 assert_eq!(grouped[1].primary.path, "docs/guide.md");
4899 assert_eq!(grouped[1].additional_matches.len(), 0);
4900 }
4901
4902 #[test]
4903 fn search_groups_chunk_results_in_default_output() {
4904 with_isolated_xdg_dirs(|| {
4905 let root = tempdir().expect("create collection root");
4906 let engine = Engine::new(None).expect("create engine");
4907 engine.add_space("work", None).expect("add work");
4908
4909 let docs_path = new_collection_dir(root.path(), "work-docs");
4910 engine
4911 .add_collection(AddCollectionRequest {
4912 path: docs_path.clone(),
4913 space: Some("work".to_string()),
4914 name: Some("docs".to_string()),
4915 description: None,
4916 extensions: None,
4917 no_index: true,
4918 })
4919 .expect("add collection");
4920
4921 fs::write(
4922 docs_path.join("big.md"),
4923 "# Big Guide\n\n## Part 1\nGuide systems depend on guide ranking.\n\
4924\n## Part 2\nGuide tuning changes guide retrieval quality.\n\
4925\n## Part 3\nGuide evaluation catches guide regressions.\n",
4926 )
4927 .expect("write big doc");
4928 fs::write(
4929 docs_path.join("small.md"),
4930 "# Small Guide\n\nguide guide guide guide\n",
4931 )
4932 .expect("write small doc");
4933
4934 let adapter = CliAdapter::new(engine);
4935 adapter
4936 .update(Some("work"), &["docs".to_string()], true, false, false)
4937 .expect("run update");
4938 let space_row = adapter
4939 .engine
4940 .storage()
4941 .get_space("work")
4942 .expect("get space");
4943 let collection_row = adapter
4944 .engine
4945 .storage()
4946 .get_collection(space_row.id, "docs")
4947 .expect("get collection");
4948 let big_document = adapter
4949 .engine
4950 .storage()
4951 .get_document_by_path(collection_row.id, "big.md")
4952 .expect("get document")
4953 .expect("document exists");
4954 let short_locator_prefix = format!("#{}@", &big_document.hash[..6]);
4955
4956 let output = adapter
4957 .search(CliSearchOptions {
4958 space: Some("work"),
4959 query: "guide",
4960 collections: &["docs".to_string()],
4961 limit: 2,
4962 min_score: 0.0,
4963 deep: false,
4964 keyword: true,
4965 semantic: false,
4966 rerank: false,
4967 no_rerank: false,
4968 debug: false,
4969 })
4970 .expect("run grouped search");
4971
4972 assert!(
4973 output.contains("2 documents"),
4974 "unexpected output:\n{output}"
4975 );
4976 assert!(
4977 output.contains("query: guide"),
4978 "unexpected output:\n{output}"
4979 );
4980 assert!(
4981 output.contains("mode: keyword"),
4982 "unexpected output:\n{output}"
4983 );
4984 assert!(
4985 output.matches("work/docs/big.md").count() == 1,
4986 "unexpected output:\n{output}"
4987 );
4988 assert!(
4989 output.contains("also found: 2 more chunks"),
4990 "unexpected output:\n{output}"
4991 );
4992 assert!(
4993 output.contains("section: Big Guide"),
4994 "primary result should label heading metadata as a section, not a literal match:\n{output}"
4995 );
4996 assert!(
4997 !output.contains("match:"),
4998 "normal search output should not imply exact match metadata:\n{output}"
4999 );
5000 assert!(
5001 output.contains("Big Guide > Part 2"),
5002 "unexpected output:\n{output}"
5003 );
5004 assert!(
5005 output.contains(&short_locator_prefix),
5006 "unexpected output:\n{output}"
5007 );
5008 assert!(
5009 !output.contains(&big_document.hash),
5010 "normal output should not expose full hash:\n{output}"
5011 );
5012
5013 let adapter = adapter.with_color(true);
5014 let colored_output = adapter
5015 .search(CliSearchOptions {
5016 space: Some("work"),
5017 query: "guide",
5018 collections: &["docs".to_string()],
5019 limit: 2,
5020 min_score: 0.0,
5021 deep: false,
5022 keyword: true,
5023 semantic: false,
5024 rerank: false,
5025 no_rerank: false,
5026 debug: false,
5027 })
5028 .expect("run colored grouped search");
5029 assert!(
5030 colored_output.contains("\x1b["),
5031 "colored search output should include ANSI emphasis:\n{colored_output}"
5032 );
5033 assert!(
5034 colored_output.contains(&short_locator_prefix),
5035 "colored output should keep compact locator:\n{colored_output}"
5036 );
5037 });
5038 }
5039
5040 #[test]
5041 fn search_debug_keeps_chunk_level_results() {
5042 with_isolated_xdg_dirs(|| {
5043 let root = tempdir().expect("create collection root");
5044 let engine = Engine::new(None).expect("create engine");
5045 engine.add_space("work", None).expect("add work");
5046
5047 let docs_path = new_collection_dir(root.path(), "work-docs");
5048 engine
5049 .add_collection(AddCollectionRequest {
5050 path: docs_path.clone(),
5051 space: Some("work".to_string()),
5052 name: Some("docs".to_string()),
5053 description: None,
5054 extensions: None,
5055 no_index: true,
5056 })
5057 .expect("add collection");
5058
5059 fs::write(
5060 docs_path.join("big.md"),
5061 "# Big Guide\n\n## Part 1\nGuide systems depend on guide ranking.\n\
5062\n## Part 2\nGuide tuning changes guide retrieval quality.\n\
5063\n## Part 3\nGuide evaluation catches guide regressions.\n",
5064 )
5065 .expect("write big doc");
5066
5067 let adapter = CliAdapter::new(engine);
5068 adapter
5069 .update(Some("work"), &["docs".to_string()], true, false, false)
5070 .expect("run update");
5071 let space_row = adapter
5072 .engine
5073 .storage()
5074 .get_space("work")
5075 .expect("get space");
5076 let collection_row = adapter
5077 .engine
5078 .storage()
5079 .get_collection(space_row.id, "docs")
5080 .expect("get collection");
5081 let document = adapter
5082 .engine
5083 .storage()
5084 .get_document_by_path(collection_row.id, "big.md")
5085 .expect("get document")
5086 .expect("document exists");
5087
5088 let adapter = adapter.with_color(true);
5089 let output = adapter
5090 .search(CliSearchOptions {
5091 space: Some("work"),
5092 query: "guide",
5093 collections: &["docs".to_string()],
5094 limit: 3,
5095 min_score: 0.0,
5096 deep: false,
5097 keyword: true,
5098 semantic: false,
5099 rerank: false,
5100 no_rerank: false,
5101 debug: true,
5102 })
5103 .expect("run debug search");
5104
5105 assert!(
5106 output.matches("work/docs/big.md").count() > 1,
5107 "unexpected output:\n{output}"
5108 );
5109 assert!(
5110 !output.contains("more matching section"),
5111 "unexpected output:\n{output}"
5112 );
5113 assert!(
5114 output.contains("results:\n- #"),
5115 "debug output should use sectioned result rows:\n{output}"
5116 );
5117 assert!(output.contains("chunk: '#"), "unexpected output:\n{output}");
5118 assert!(output.contains("snippet:"), "unexpected output:\n{output}");
5119 assert!(
5120 output.contains("elapsed:\n- "),
5121 "unexpected output:\n{output}"
5122 );
5123 assert!(
5124 output.contains(&document.hash),
5125 "debug output should preserve full chunk locator:\n{output}"
5126 );
5127 assert!(
5128 !output.contains("\x1b["),
5129 "debug output should stay plain even if color is enabled:\n{output}"
5130 );
5131 });
5132 }
5133
5134 #[test]
5135 fn status_response_formats_human_storage_and_model_summary() {
5136 let output = format_status_response(
5137 &StatusResponse {
5138 spaces: vec![SpaceStatus {
5139 name: "default".to_string(),
5140 description: Some("main workspace".to_string()),
5141 last_updated: Some("2026-04-11T16:49:07Z".to_string()),
5142 collections: vec![CollectionStatus {
5143 name: "kbolt".to_string(),
5144 path: PathBuf::from("/Users/macbook/kbolt"),
5145 documents: 98,
5146 active_documents: 98,
5147 chunks: 1218,
5148 embedded_chunks: 1218,
5149 last_updated: "2026-04-11T16:49:07Z".to_string(),
5150 }],
5151 }],
5152 models: kbolt_types::ModelStatus {
5153 embedder: ModelInfo {
5154 configured: true,
5155 ready: false,
5156 profile: Some("kbolt_local_embed".to_string()),
5157 kind: Some("llama_cpp_server".to_string()),
5158 operation: Some("embedding".to_string()),
5159 model: Some("embeddinggemma".to_string()),
5160 endpoint: Some("http://127.0.0.1:8103".to_string()),
5161 issue: Some("endpoint is unreachable".to_string()),
5162 },
5163 reranker: ModelInfo {
5164 configured: true,
5165 ready: true,
5166 profile: Some("kbolt_local_rerank".to_string()),
5167 kind: Some("llama_cpp_server".to_string()),
5168 operation: Some("reranking".to_string()),
5169 model: Some("qwen3-reranker".to_string()),
5170 endpoint: Some("http://127.0.0.1:8104".to_string()),
5171 issue: None,
5172 },
5173 expander: ModelInfo {
5174 configured: false,
5175 ready: false,
5176 profile: None,
5177 kind: None,
5178 operation: None,
5179 model: None,
5180 endpoint: None,
5181 issue: None,
5182 },
5183 },
5184 cache_dir: PathBuf::from("/Users/macbook/Library/Caches/kbolt"),
5185 config_dir: PathBuf::from("/Users/macbook/Library/Application Support/kbolt"),
5186 total_documents: 98,
5187 total_chunks: 1218,
5188 total_embedded: 1218,
5189 disk_usage: DiskUsage {
5190 sqlite_bytes: 348_160,
5191 tantivy_bytes: 520_111,
5192 usearch_bytes: 4_581_056,
5193 models_bytes: 1_935_460_432,
5194 total_bytes: 1_940_909_759,
5195 },
5196 },
5197 Some("default"),
5198 );
5199
5200 assert!(output.contains("spaces:"));
5201 assert!(output.contains("- default (active)"));
5202 assert!(output.contains(" description: main workspace"));
5203 assert!(output.contains(" collections:"));
5204 assert!(output.contains(" - kbolt"));
5205 assert!(output.contains("storage:"));
5206 assert!(
5207 output.contains("- sqlite: 340 KB"),
5208 "unexpected output:\n{output}"
5209 );
5210 assert!(
5211 output.contains("- tantivy: 508 KB"),
5212 "unexpected output:\n{output}"
5213 );
5214 assert!(
5215 output.contains("- vectors: 4.4 MB"),
5216 "unexpected output:\n{output}"
5217 );
5218 assert!(
5219 output.contains("- models: 1.8 GB"),
5220 "unexpected output:\n{output}"
5221 );
5222 assert!(
5223 output.contains("- total: 1.8 GB"),
5224 "unexpected output:\n{output}"
5225 );
5226 assert!(output.contains("- embedder: not ready (embeddinggemma)"));
5227 assert!(output.contains(" issue: endpoint is unreachable"));
5228 assert!(!output.contains("profile="), "unexpected output:\n{output}");
5229 }
5230
5231 #[test]
5232 fn active_space_name_for_status_follows_cli_precedence_without_validation() {
5233 with_isolated_xdg_dirs(|| {
5234 let root = tempdir().expect("create config root");
5235 std::fs::write(
5236 root.path().join("index.toml"),
5237 "default_space = \"default\"\n",
5238 )
5239 .expect("write config");
5240 let engine = Engine::new(Some(root.path())).expect("create engine");
5241
5242 std::env::remove_var("KBOLT_SPACE");
5243 assert_eq!(
5244 active_space_name_for_status(&engine, Some("work")).as_deref(),
5245 Some("work")
5246 );
5247
5248 std::env::set_var("KBOLT_SPACE", "ops");
5249 assert_eq!(
5250 active_space_name_for_status(&engine, None).as_deref(),
5251 Some("ops")
5252 );
5253 std::env::remove_var("KBOLT_SPACE");
5254
5255 assert_eq!(
5256 active_space_name_for_status(&engine, None).as_deref(),
5257 Some("default")
5258 );
5259 });
5260 }
5261
5262 #[test]
5263 fn optional_search_signal_uses_human_values() {
5264 assert_eq!(format_optional_search_signal(None), "-");
5265 assert_eq!(format_optional_search_signal(Some(0.824)), "0.82");
5266 }
5267
5268 #[test]
5269 fn file_list_hides_docids_by_default() {
5270 let output = format_file_list(
5271 &[
5272 FileEntry {
5273 path: "docs/keep.md".to_string(),
5274 title: "keep.md".to_string(),
5275 docid: "#3c96dd".to_string(),
5276 active: true,
5277 chunk_count: 1,
5278 embedded: false,
5279 },
5280 FileEntry {
5281 path: "docs/old.md".to_string(),
5282 title: "old.md".to_string(),
5283 docid: "#deadbe".to_string(),
5284 active: false,
5285 chunk_count: 1,
5286 embedded: false,
5287 },
5288 ],
5289 false,
5290 );
5291
5292 assert!(
5293 output.contains("- docs/keep.md (1 chunk(s), not embedded)"),
5294 "unexpected output:\n{output}"
5295 );
5296 assert!(!output.contains("#3c96dd"), "unexpected output:\n{output}");
5297 assert!(
5298 !output.contains("keep.md |"),
5299 "unexpected output:\n{output}"
5300 );
5301 }
5302
5303 #[test]
5304 fn file_list_marks_inactive_files_with_all() {
5305 let output = format_file_list(
5306 &[FileEntry {
5307 path: "docs/old.md".to_string(),
5308 title: "old.md".to_string(),
5309 docid: "#deadbe".to_string(),
5310 active: false,
5311 chunk_count: 1,
5312 embedded: false,
5313 }],
5314 true,
5315 );
5316
5317 assert!(output.contains("- docs/old.md (inactive, 1 chunk(s), not embedded)"));
5318 assert!(!output.contains("#deadbe"));
5319 }
5320
5321 #[test]
5322 fn color_file_list_marks_paths_and_problem_states() {
5323 let output = format_file_list_color(
5324 &[
5325 FileEntry {
5326 path: "docs/keep.md".to_string(),
5327 title: "keep.md".to_string(),
5328 docid: "#3c96dd".to_string(),
5329 active: true,
5330 chunk_count: 1,
5331 embedded: true,
5332 },
5333 FileEntry {
5334 path: "docs/old.md".to_string(),
5335 title: "old.md".to_string(),
5336 docid: "#deadbe".to_string(),
5337 active: false,
5338 chunk_count: 1,
5339 embedded: false,
5340 },
5341 ],
5342 true,
5343 true,
5344 );
5345
5346 assert!(output.contains("\x1b[34mdocs/keep.md\x1b[0m"));
5347 assert!(output.contains("\x1b[90membedded\x1b[0m"));
5348 assert!(output.contains("\x1b[33minactive\x1b[0m"));
5349 assert!(output.contains("\x1b[33mnot embedded\x1b[0m"));
5350 assert!(!output.contains("#deadbe"));
5351 }
5352
5353 #[test]
5354 fn ignore_commands_use_sectioned_output() {
5355 with_isolated_xdg_dirs(|| {
5356 let root = tempdir().expect("create collection root");
5357 let engine = Engine::new(None).expect("create engine");
5358 engine.add_space("work", None).expect("add work");
5359 engine
5360 .add_collection(AddCollectionRequest {
5361 path: new_collection_dir(root.path(), "work-api"),
5362 space: Some("work".to_string()),
5363 name: Some("api".to_string()),
5364 description: None,
5365 extensions: Some(vec!["md".to_string()]),
5366 no_index: true,
5367 })
5368 .expect("add collection");
5369 let adapter = CliAdapter::new(engine);
5370
5371 let empty = adapter
5372 .ignore_show(Some("work"), "api")
5373 .expect("show empty ignore");
5374 assert!(empty.contains("ignore: work/api"));
5375 assert!(empty.contains("contents:"));
5376 assert!(empty.contains("- none"));
5377
5378 let added = adapter
5379 .ignore_add(Some("work"), "api", "dist/")
5380 .expect("add ignore pattern");
5381 assert!(added.contains("ignore updated: work/api"));
5382 assert!(added.contains("added:"));
5383 assert!(added.contains("- dist/"));
5384
5385 let shown = adapter
5386 .ignore_show(Some("work"), "api")
5387 .expect("show ignore pattern");
5388 assert!(shown.contains("contents:"));
5389 assert!(shown.contains("- dist/"));
5390
5391 let listed = adapter
5392 .ignore_list(Some("work"))
5393 .expect("list ignore files");
5394 assert!(listed.contains("ignore files:"));
5395 assert!(listed.contains("- work/api: 1 pattern(s)"));
5396
5397 let missing = adapter
5398 .ignore_remove(Some("work"), "api", "target/")
5399 .expect("remove missing ignore pattern");
5400 assert!(missing.contains("ignore unchanged: work/api"));
5401 assert!(missing.contains("missing:"));
5402 assert!(missing.contains("- target/"));
5403
5404 let removed = adapter
5405 .ignore_remove(Some("work"), "api", "dist/")
5406 .expect("remove ignore pattern");
5407 assert!(removed.contains("ignore updated: work/api"));
5408 assert!(removed.contains("removed:"));
5409 assert!(removed.contains("- dist/"));
5410 assert!(removed.contains("matches:"));
5411 assert!(removed.contains("- 1"));
5412 });
5413 }
5414
5415 #[test]
5416 fn ignore_show_labels_comments_as_contents_not_patterns() {
5417 with_isolated_xdg_dirs(|| {
5418 let root = tempdir().expect("create collection root");
5419 let engine = Engine::new(None).expect("create engine");
5420 engine.add_space("work", None).expect("add work");
5421 engine
5422 .add_collection(AddCollectionRequest {
5423 path: new_collection_dir(root.path(), "work-api"),
5424 space: Some("work".to_string()),
5425 name: Some("api".to_string()),
5426 description: None,
5427 extensions: Some(vec!["md".to_string()]),
5428 no_index: true,
5429 })
5430 .expect("add collection");
5431 let adapter = CliAdapter::new(engine);
5432
5433 adapter
5434 .ignore_add(Some("work"), "api", "# generated files")
5435 .expect("add comment line");
5436
5437 let shown = adapter
5438 .ignore_show(Some("work"), "api")
5439 .expect("show ignore contents");
5440 assert!(shown.contains("contents:"));
5441 assert!(!shown.contains("patterns:"), "unexpected output:\n{shown}");
5442 assert!(shown.contains("- # generated files"));
5443
5444 let listed = adapter
5445 .ignore_list(Some("work"))
5446 .expect("list ignore files");
5447 assert!(listed.contains("- work/api: 0 pattern(s)"));
5448 });
5449 }
5450
5451 #[test]
5452 fn ignore_show_preserves_pattern_spacing_for_copy_paste() {
5453 with_isolated_xdg_dirs(|| {
5454 let root = tempdir().expect("create collection root");
5455 let engine = Engine::new(None).expect("create engine");
5456 engine.add_space("work", None).expect("add work");
5457 engine
5458 .add_collection(AddCollectionRequest {
5459 path: new_collection_dir(root.path(), "work-api"),
5460 space: Some("work".to_string()),
5461 name: Some("api".to_string()),
5462 description: None,
5463 extensions: Some(vec!["md".to_string()]),
5464 no_index: true,
5465 })
5466 .expect("add collection");
5467 let adapter = CliAdapter::new(engine);
5468
5469 adapter
5470 .ignore_add(Some("work"), "api", " build/ ")
5471 .expect("add spaced pattern");
5472
5473 let shown = adapter
5474 .ignore_show(Some("work"), "api")
5475 .expect("show ignore contents");
5476 assert!(
5477 shown.lines().any(|line| line == "- build/ "),
5478 "expected exact spacing in output:\n{shown}"
5479 );
5480 });
5481 }
5482
5483 #[test]
5484 fn colored_ignore_show_preserves_pattern_spacing_for_copy_paste() {
5485 with_isolated_xdg_dirs(|| {
5486 let root = tempdir().expect("create collection root");
5487 let engine = Engine::new(None).expect("create engine");
5488 engine.add_space("work", None).expect("add work");
5489 engine
5490 .add_collection(AddCollectionRequest {
5491 path: new_collection_dir(root.path(), "work-api"),
5492 space: Some("work".to_string()),
5493 name: Some("api".to_string()),
5494 description: None,
5495 extensions: Some(vec!["md".to_string()]),
5496 no_index: true,
5497 })
5498 .expect("add collection");
5499 let adapter = CliAdapter::new(engine).with_color(true);
5500
5501 adapter
5502 .ignore_add(Some("work"), "api", " build/ ")
5503 .expect("add spaced pattern");
5504
5505 let shown = adapter
5506 .ignore_show(Some("work"), "api")
5507 .expect("show ignore contents");
5508 assert!(shown.contains("\x1b[1;36mcontents:\x1b[0m"));
5509 assert!(
5510 shown.lines().any(|line| line == "- build/ "),
5511 "expected exact spacing in output:\n{shown}"
5512 );
5513 });
5514 }
5515
5516 #[test]
5517 fn ignore_edit_reports_collection_and_path() {
5518 with_isolated_xdg_dirs(|| {
5519 let root = tempdir().expect("create collection root");
5520 let engine = Engine::new(None).expect("create engine");
5521 engine.add_space("work", None).expect("add work");
5522 engine
5523 .add_collection(AddCollectionRequest {
5524 path: new_collection_dir(root.path(), "work-api"),
5525 space: Some("work".to_string()),
5526 name: Some("api".to_string()),
5527 description: None,
5528 extensions: Some(vec!["md".to_string()]),
5529 no_index: true,
5530 })
5531 .expect("add collection");
5532 let adapter = CliAdapter::new(engine);
5533
5534 std::env::set_var("VISUAL", "/usr/bin/true");
5535 std::env::remove_var("EDITOR");
5536 let output = adapter
5537 .ignore_edit(Some("work"), "api")
5538 .expect("edit ignore file");
5539
5540 assert!(output.contains("ignore updated: work/api"));
5541 assert!(output.contains(" path:"));
5542 });
5543 }
5544
5545 #[test]
5546 fn collection_info_is_human_readable() {
5547 let output = format_collection_info(&CollectionInfo {
5548 name: "api".to_string(),
5549 space: "work".to_string(),
5550 path: PathBuf::from("/tmp/work-api"),
5551 description: Some("API reference".to_string()),
5552 extensions: Some(vec!["md".to_string(), "txt".to_string()]),
5553 document_count: 97,
5554 active_document_count: 96,
5555 chunk_count: 1183,
5556 embedded_chunk_count: 1180,
5557 created: "2026-04-13T12:00:00Z".to_string(),
5558 updated: "2026-04-14T09:30:00Z".to_string(),
5559 });
5560
5561 assert!(output.contains("collection: work/api"));
5562 assert!(output.contains("path: /tmp/work-api"));
5563 assert!(output.contains("description: API reference"));
5564 assert!(output.contains("extensions: md, txt"));
5565 assert!(output.contains("documents:"));
5566 assert!(output.contains("- 96 active / 97 total"));
5567 assert!(output.contains("- 1183 chunks"));
5568 assert!(output.contains("- 1180 embedded"));
5569 assert!(output.contains("timestamps:"));
5570 assert!(output.contains("- created 2026-04-13T12:00:00Z"));
5571 assert!(output.contains("- updated 2026-04-14T09:30:00Z"));
5572 assert!(
5573 !output.contains("active_documents:"),
5574 "unexpected output:\n{output}"
5575 );
5576 }
5577
5578 #[test]
5579 fn document_response_is_human_readable() {
5580 let output = format_document_response(
5581 &DocumentResponse {
5582 docid: "#409380".to_string(),
5583 path: "kbolt/README.md".to_string(),
5584 title: "README".to_string(),
5585 space: "default".to_string(),
5586 collection: "kbolt".to_string(),
5587 content: "line one\nline two".to_string(),
5588 stale: false,
5589 total_lines: 68,
5590 returned_lines: 5,
5591 },
5592 false,
5593 );
5594
5595 assert!(output.contains("document: default/kbolt/README.md"));
5596 assert!(output.contains("title: README"));
5597 assert!(output.contains("docid: #409380"));
5598 assert!(
5599 !output.contains("shown:"),
5600 "human get output should not expose logical line counts:\n{output}"
5601 );
5602 assert!(output.contains("\n\nline one\nline two"));
5603 assert!(
5604 !output.contains("stale: false"),
5605 "unexpected output:\n{output}"
5606 );
5607 assert!(!output.contains("content:"), "unexpected output:\n{output}");
5608 assert!(
5609 !output.contains("collection: kbolt"),
5610 "unexpected output:\n{output}"
5611 );
5612 }
5613
5614 #[test]
5615 fn colored_document_response_keeps_body_plain() {
5616 let output = format_document_response(
5617 &DocumentResponse {
5618 docid: "#409380".to_string(),
5619 path: "kbolt/README.md".to_string(),
5620 title: "README".to_string(),
5621 space: "default".to_string(),
5622 collection: "kbolt".to_string(),
5623 content: "plain body line\n- copyable bullet".to_string(),
5624 stale: false,
5625 total_lines: 2,
5626 returned_lines: 2,
5627 },
5628 true,
5629 );
5630
5631 assert!(output.contains("document: \x1b[34mdefault/kbolt/README.md\x1b[0m"));
5632 assert!(output.lines().any(|line| line == "plain body line"));
5633 assert!(output.lines().any(|line| line == "- copyable bullet"));
5634 }
5635
5636 #[test]
5637 fn chunk_response_is_human_readable() {
5638 let output = format_chunk_response(
5639 &ChunkResponse {
5640 locator: "#409380badc0ffee@3".to_string(),
5641 docid: "#409380".to_string(),
5642 chunk_ordinal: 3,
5643 path: "kbolt/README.md".to_string(),
5644 title: "README".to_string(),
5645 space: "default".to_string(),
5646 collection: "kbolt".to_string(),
5647 heading: Some("Install".to_string()),
5648 content: "line one\nline two".to_string(),
5649 stale: false,
5650 total_lines: 12,
5651 returned_lines: 2,
5652 },
5653 "#409380@3",
5654 false,
5655 );
5656
5657 assert!(output.contains("README #409380@3"));
5658 assert!(output.contains("path: default/kbolt/README.md"));
5659 assert!(
5660 !output.contains("#409380badc0ffee@3"),
5661 "human chunk output should not expand compact handles:\n{output}"
5662 );
5663 assert!(output.contains("section: Install"));
5664 assert!(
5665 !output.contains("shown:"),
5666 "human chunk output should not expose logical line counts:\n{output}"
5667 );
5668 assert!(output.contains("\n\nline one\nline two"));
5669 assert!(
5670 !output.contains("stale: false"),
5671 "unexpected output:\n{output}"
5672 );
5673 assert!(!output.contains("content:"), "unexpected output:\n{output}");
5674 assert!(
5675 !output.contains("collection: kbolt"),
5676 "unexpected output:\n{output}"
5677 );
5678 }
5679
5680 #[test]
5681 fn colored_chunk_response_keeps_body_plain() {
5682 let output = format_chunk_response(
5683 &ChunkResponse {
5684 locator: "#409380badc0ffee@3".to_string(),
5685 docid: "#409380".to_string(),
5686 chunk_ordinal: 3,
5687 path: "kbolt/README.md".to_string(),
5688 title: "README".to_string(),
5689 space: "default".to_string(),
5690 collection: "kbolt".to_string(),
5691 heading: Some("Install".to_string()),
5692 content: "plain chunk line\n- copyable bullet".to_string(),
5693 stale: false,
5694 total_lines: 2,
5695 returned_lines: 2,
5696 },
5697 "#409380@3",
5698 true,
5699 );
5700
5701 assert!(output.contains("\x1b[34m#409380@3\x1b[0m"));
5702 assert!(output.lines().any(|line| line == "plain chunk line"));
5703 assert!(output.lines().any(|line| line == "- copyable bullet"));
5704 }
5705
5706 #[test]
5707 fn multi_get_response_is_human_readable() {
5708 let mut chunk_display_locators = std::collections::HashMap::new();
5709 chunk_display_locators.insert("#409380badc0ffee@3".to_string(), "#409380@3".to_string());
5710 chunk_display_locators.insert("#409380badc0ffee@4".to_string(), "#409380@4".to_string());
5711 let response = MultiGetResponse {
5712 items: vec![
5713 MultiGetItem::Document(DocumentResponse {
5714 docid: "#409380".to_string(),
5715 path: "kbolt/README.md".to_string(),
5716 title: "README".to_string(),
5717 space: "default".to_string(),
5718 collection: "kbolt".to_string(),
5719 content: "line one\nline two".to_string(),
5720 stale: false,
5721 total_lines: 68,
5722 returned_lines: 68,
5723 }),
5724 MultiGetItem::Document(DocumentResponse {
5725 docid: "#abcd12".to_string(),
5726 path: "api/guide.md".to_string(),
5727 title: "Guide".to_string(),
5728 space: "work".to_string(),
5729 collection: "api".to_string(),
5730 content: "guide body".to_string(),
5731 stale: true,
5732 total_lines: 12,
5733 returned_lines: 4,
5734 }),
5735 MultiGetItem::Chunk(ChunkResponse {
5736 locator: "#409380badc0ffee@3".to_string(),
5737 docid: "#409380".to_string(),
5738 chunk_ordinal: 3,
5739 path: "kbolt/README.md".to_string(),
5740 title: "README".to_string(),
5741 space: "default".to_string(),
5742 collection: "kbolt".to_string(),
5743 heading: Some("Install".to_string()),
5744 content: "chunk body".to_string(),
5745 stale: false,
5746 total_lines: 1,
5747 returned_lines: 1,
5748 }),
5749 ],
5750 omitted: vec![
5751 OmittedItem {
5752 kind: MultiGetItemKind::Document,
5753 locator: "#large1".to_string(),
5754 path: "api/large.md".to_string(),
5755 docid: "#large1".to_string(),
5756 space: "work".to_string(),
5757 size_bytes: 8192,
5758 reason: OmitReason::MaxBytes,
5759 },
5760 OmittedItem {
5761 kind: MultiGetItemKind::Chunk,
5762 locator: "#409380badc0ffee@4".to_string(),
5763 path: "kbolt/README.md".to_string(),
5764 docid: "#409380".to_string(),
5765 space: "default".to_string(),
5766 size_bytes: 4096,
5767 reason: OmitReason::MaxFiles,
5768 },
5769 ],
5770 resolved_count: 4,
5771 warnings: vec!["item not found: api/missing.md".to_string()],
5772 };
5773 let output = format_multi_get_response_color_with_chunk_locators(
5774 &response,
5775 false,
5776 &chunk_display_locators,
5777 );
5778
5779 assert!(output.contains("items:\n- 3 returned"));
5780 assert!(output.contains("- 4 resolved"));
5781 assert!(output.contains("1. default/kbolt/README.md"));
5782 assert!(output.contains(" title: README"));
5783 assert!(output.contains(" docid: #409380"));
5784 assert!(
5785 !output.contains(" shown:"),
5786 "multi-get output should not expose logical line counts:\n{output}"
5787 );
5788 assert!(output.contains("2. work/api/guide.md"));
5789 assert!(output.contains(" status: stale"));
5790 assert!(output.contains("3. default/kbolt/README.md"));
5791 assert!(output.contains(" locator: #409380@3"));
5792 assert!(output.contains(" section: Install"));
5793 assert!(output.contains("chunk body"));
5794 assert!(
5795 !output.contains("shown: 4 of 12 lines"),
5796 "multi-get output should not expose logical line counts:\n{output}"
5797 );
5798 assert!(output.contains("omitted:"));
5799 assert!(output.contains("- api/large.md (8.0 KB, size limit)"));
5800 assert!(output.contains("- #409380@4 in kbolt/README.md (4.0 KB, max files)"));
5801 assert!(
5802 !output.contains("#409380badc0ffee"),
5803 "multi-get human output should use compact chunk locators:\n{output}"
5804 );
5805 assert!(output.contains("warnings:"));
5806 assert!(output.contains("- item not found: api/missing.md"));
5807 assert!(
5808 !output.contains("resolved_count:"),
5809 "unexpected output:\n{output}"
5810 );
5811 assert!(
5812 !output.contains("--- #409380"),
5813 "unexpected output:\n{output}"
5814 );
5815 }
5816
5817 #[test]
5818 fn multi_get_response_formats_empty_state() {
5819 let output = format_multi_get_response(&MultiGetResponse {
5820 items: Vec::new(),
5821 omitted: Vec::new(),
5822 resolved_count: 0,
5823 warnings: Vec::new(),
5824 });
5825
5826 assert_eq!(output, "items:\n- none returned");
5827 }
5828
5829 #[test]
5830 fn multi_get_response_reports_resolved_count_when_all_omitted() {
5831 let output = format_multi_get_response(&MultiGetResponse {
5832 items: Vec::new(),
5833 omitted: vec![OmittedItem {
5834 kind: MultiGetItemKind::Document,
5835 locator: "#large1".to_string(),
5836 path: "api/large.md".to_string(),
5837 docid: "#large1".to_string(),
5838 space: "work".to_string(),
5839 size_bytes: 8192,
5840 reason: OmitReason::MaxBytes,
5841 }],
5842 resolved_count: 1,
5843 warnings: Vec::new(),
5844 });
5845
5846 assert!(output.contains("items:\n- none returned\n- 1 resolved"));
5847 assert!(output.contains("omitted:\n- api/large.md (8.0 KB, size limit)"));
5848 }
5849
5850 #[test]
5851 fn update_verbose_reports_buffered_decisions_before_summary() {
5852 with_isolated_xdg_dirs(|| {
5853 let root = tempdir().expect("create collection root");
5854 let engine = Engine::new(None).expect("create engine");
5855 engine.add_space("work", None).expect("add work");
5856
5857 let collection_path = new_collection_dir(root.path(), "work-api");
5858 engine
5859 .add_collection(AddCollectionRequest {
5860 path: collection_path.clone(),
5861 space: Some("work".to_string()),
5862 name: Some("api".to_string()),
5863 description: None,
5864 extensions: Some(vec!["rs".to_string()]),
5865 no_index: true,
5866 })
5867 .expect("add collection");
5868 let adapter = CliAdapter::new(engine);
5869
5870 fs::create_dir_all(collection_path.join("src")).expect("create src dir");
5871 fs::write(collection_path.join("src/lib.rs"), "fn alpha() {}\n")
5872 .expect("write valid file");
5873 fs::write(collection_path.join("src/bad.rs"), [0xff, 0xfe, 0xfd])
5874 .expect("write invalid file");
5875
5876 let output = adapter
5877 .update(Some("work"), &["api".to_string()], true, false, true)
5878 .expect("run verbose update");
5879
5880 let summary_index = output
5881 .lines()
5882 .position(|line| line == "update complete")
5883 .expect("expected summary output");
5884 assert!(summary_index > 0, "unexpected output: {output}");
5885 assert!(
5886 output.lines().next().unwrap_or_default() == "decisions:",
5887 "unexpected output: {output}"
5888 );
5889 assert!(
5890 output.contains("- work/api/src/lib.rs: new"),
5891 "unexpected output: {output}"
5892 );
5893 assert!(
5894 output.contains("- work/api/src/bad.rs: extract_failed (extract failed:"),
5895 "unexpected output: {output}"
5896 );
5897 assert!(output.contains("summary:"), "unexpected output: {output}");
5898 assert!(
5899 output.contains("- 2 document(s) scanned"),
5900 "unexpected output: {output}"
5901 );
5902 });
5903 }
5904
5905 #[test]
5906 fn collection_add_result_formats_no_index_message() {
5907 let output = format_collection_add_result(&AddCollectionResult {
5908 collection: CollectionInfo {
5909 name: "api".to_string(),
5910 space: "work".to_string(),
5911 path: PathBuf::from("/tmp/work-api"),
5912 description: None,
5913 extensions: None,
5914 document_count: 0,
5915 active_document_count: 0,
5916 chunk_count: 0,
5917 embedded_chunk_count: 0,
5918 created: "2026-03-31T00:00:00Z".to_string(),
5919 updated: "2026-03-31T00:00:00Z".to_string(),
5920 },
5921 initial_indexing: InitialIndexingOutcome::Skipped,
5922 });
5923
5924 assert!(output.contains("collection added: work/api"));
5925 assert!(output.contains("indexing:"));
5926 assert!(output.contains("- skipped (--no-index)"));
5927 assert!(output.contains("next:"));
5928 assert!(output.contains("- kbolt --space work update --collection api"));
5929 }
5930
5931 #[test]
5932 fn collection_add_result_formats_incomplete_initial_indexing() {
5933 let output = format_collection_add_result(&AddCollectionResult {
5934 collection: CollectionInfo {
5935 name: "api".to_string(),
5936 space: "work".to_string(),
5937 path: PathBuf::from("/tmp/work-api"),
5938 description: None,
5939 extensions: None,
5940 document_count: 3,
5941 active_document_count: 3,
5942 chunk_count: 3,
5943 embedded_chunk_count: 2,
5944 created: "2026-03-31T00:00:00Z".to_string(),
5945 updated: "2026-03-31T00:00:00Z".to_string(),
5946 },
5947 initial_indexing: InitialIndexingOutcome::Indexed(UpdateReport {
5948 scanned_docs: 3,
5949 skipped_mtime_docs: 0,
5950 skipped_hash_docs: 0,
5951 added_docs: 2,
5952 updated_docs: 0,
5953 failed_docs: 1,
5954 deactivated_docs: 0,
5955 reactivated_docs: 0,
5956 reaped_docs: 0,
5957 embedded_chunks: 2,
5958 decisions: Vec::new(),
5959 errors: vec![kbolt_types::FileError {
5960 path: "work/api/bad.md".to_string(),
5961 error: "extract failed".to_string(),
5962 }],
5963 elapsed_ms: 5,
5964 }),
5965 });
5966
5967 assert!(output.contains("collection added: work/api"));
5968 assert!(output.contains("initial indexing incomplete"));
5969 assert!(output.contains("summary:"));
5970 assert!(output.contains("- 3 document(s) scanned"));
5971 assert!(output.contains("- 2 added"));
5972 assert!(output.contains("- 1 failed"));
5973 assert!(output.contains("- 2 chunk(s) embedded"));
5974 assert!(output.contains("- completed in 5ms"));
5975 assert!(output.contains("errors:"));
5976 assert!(output.contains("- work/api/bad.md: extract failed"));
5977 assert!(output.contains("next:"));
5978 assert!(output.contains("- kbolt --space work update --collection api"));
5979 }
5980
5981 #[test]
5982 fn collection_add_result_formats_model_block_with_resume_steps() {
5983 let output = format_collection_add_result(&AddCollectionResult {
5984 collection: CollectionInfo {
5985 name: "api".to_string(),
5986 space: "work".to_string(),
5987 path: PathBuf::from("/tmp/work-api"),
5988 description: None,
5989 extensions: None,
5990 document_count: 0,
5991 active_document_count: 0,
5992 chunk_count: 0,
5993 embedded_chunk_count: 0,
5994 created: "2026-03-31T00:00:00Z".to_string(),
5995 updated: "2026-03-31T00:00:00Z".to_string(),
5996 },
5997 initial_indexing: InitialIndexingOutcome::Blocked(
5998 InitialIndexingBlock::ModelNotAvailable {
5999 name: "embed-model".to_string(),
6000 },
6001 ),
6002 });
6003
6004 assert!(output.contains("collection added: work/api"));
6005 assert!(output.contains("indexing blocked: model 'embed-model' is not available"));
6006 assert!(output.contains("next:"));
6007 assert!(output.contains("- kbolt setup local"));
6008 assert!(output.contains("- or configure [roles.embedder] in index.toml"));
6009 assert!(output.contains("- then run: kbolt --space work update --collection api"));
6010 }
6011
6012 #[test]
6013 fn update_report_is_human_readable() {
6014 let output = format_update_report(
6015 &UpdateReport {
6016 scanned_docs: 12,
6017 skipped_mtime_docs: 5,
6018 skipped_hash_docs: 1,
6019 added_docs: 3,
6020 updated_docs: 2,
6021 failed_docs: 1,
6022 deactivated_docs: 0,
6023 reactivated_docs: 1,
6024 reaped_docs: 0,
6025 embedded_chunks: 8,
6026 decisions: Vec::new(),
6027 errors: vec![kbolt_types::FileError {
6028 path: "work/api/src/bad.rs".to_string(),
6029 error: "extract failed".to_string(),
6030 }],
6031 elapsed_ms: 1_250,
6032 },
6033 false,
6034 false,
6035 );
6036
6037 assert!(output.starts_with("update complete"));
6038 assert!(output.contains("summary:"));
6039 assert!(output.contains("- 12 document(s) scanned"));
6040 assert!(output.contains("- 6 unchanged"));
6041 assert!(output.contains("- 3 added"));
6042 assert!(output.contains("- 2 updated"));
6043 assert!(output.contains("- 1 failed"));
6044 assert!(output.contains("- 1 reactivated"));
6045 assert!(output.contains("- 8 chunk(s) embedded"));
6046 assert!(output.contains("- completed in 1.2s"));
6047 assert!(output.contains("errors:"));
6048 assert!(output.contains("- work/api/src/bad.rs: extract failed"));
6049 assert!(
6050 !output.contains("scanned_docs:"),
6051 "unexpected output:\n{output}"
6052 );
6053 }
6054
6055 #[test]
6056 fn update_report_mentions_no_embed_skip() {
6057 let report = make_update_report(Vec::new(), 0);
6058 let output = format_update_report(&report, false, true);
6059
6060 assert!(
6061 output.contains("- embedding skipped (--no-embed)"),
6062 "expected no-embed summary line: {output}"
6063 );
6064 assert!(
6065 output.contains("- completed in 0ms"),
6066 "expected elapsed summary to remain present: {output}"
6067 );
6068 }
6069
6070 #[test]
6071 fn colored_update_report_uses_warning_error_and_action_colors() {
6072 let report = make_update_report(
6073 vec![
6074 make_file_error("a.md"),
6075 make_file_error("b.md"),
6076 make_file_error("c.md"),
6077 make_file_error("d.md"),
6078 ],
6079 1,
6080 );
6081 let output = format_update_report_color(&report, false, true, true);
6082
6083 assert!(output.contains("\x1b[31m1 failed\x1b[0m"));
6084 assert!(output.contains("\x1b[31ma.md: test failure\x1b[0m"));
6085 assert!(output.contains("\x1b[33membedding skipped (--no-embed)\x1b[0m"));
6086 assert!(output.contains("\x1b[32mrun with --verbose for the full error list\x1b[0m"));
6087 }
6088
6089 #[test]
6090 fn colored_collection_add_skip_marks_status_and_next_action() {
6091 let result = AddCollectionResult {
6092 collection: make_collection_info("team notes", "cold docs"),
6093 initial_indexing: InitialIndexingOutcome::Skipped,
6094 };
6095 let output = format_collection_add_result_color(&result, true);
6096
6097 assert!(output.contains("\x1b[33mskipped (--no-index)\x1b[0m"));
6098 assert!(
6099 output.contains(
6100 "\x1b[32mkbolt --space 'team notes' update --collection 'cold docs'\x1b[0m"
6101 ),
6102 "expected colored, quoted next command: {output}"
6103 );
6104 assert!(!output.contains("\x1b[32mcollection added"));
6105
6106 let plain_output = format_collection_add_result_color(&result, false);
6107 assert!(!plain_output.contains("\x1b["));
6108 assert!(plain_output.contains("- skipped (--no-index)"));
6109 assert!(
6110 plain_output.contains("- kbolt --space 'team notes' update --collection 'cold docs'")
6111 );
6112 }
6113
6114 #[test]
6115 fn update_verbose_reports_unmatched_errors_before_summary() {
6116 let report = UpdateReport {
6117 scanned_docs: 1,
6118 skipped_mtime_docs: 0,
6119 skipped_hash_docs: 0,
6120 added_docs: 0,
6121 updated_docs: 0,
6122 failed_docs: 1,
6123 deactivated_docs: 0,
6124 reactivated_docs: 0,
6125 reaped_docs: 0,
6126 embedded_chunks: 0,
6127 decisions: Vec::new(),
6128 errors: vec![kbolt_types::FileError {
6129 path: "work/api/missing.md".to_string(),
6130 error: "read failed".to_string(),
6131 }],
6132 elapsed_ms: 4,
6133 };
6134 let output = format_update_report(&report, true, false);
6135
6136 assert!(
6137 output.starts_with("errors:\n- work/api/missing.md: read failed"),
6138 "unexpected output:\n{output}"
6139 );
6140 assert!(output.contains("\n\nupdate complete\n"));
6141 assert!(output.contains("summary:"));
6142 assert!(output.contains("- 1 failed"));
6143 }
6144
6145 #[test]
6146 fn format_elapsed_ms_uses_human_units() {
6147 assert_eq!(format_elapsed_ms(8), "8ms");
6148 assert_eq!(format_elapsed_ms(1_250), "1.2s");
6149 assert_eq!(format_elapsed_ms(125_000), "2.1m");
6150 }
6151
6152 #[test]
6153 fn space_acknowledgements_remain_plain_without_color() {
6154 assert_eq!(
6155 format_space_add_response("work", false),
6156 "space added: work"
6157 );
6158 assert_eq!(
6159 format_space_describe_response("work", false),
6160 "space description updated: work"
6161 );
6162 assert_eq!(
6163 format_space_rename_response("work", "team", false),
6164 "space renamed: work -> team"
6165 );
6166 assert_eq!(
6167 format_space_remove_response("team", false),
6168 "removed: space team"
6169 );
6170 assert_eq!(
6171 format_space_remove_default_response(false),
6172 "removed: space default\ndefault space: cleared"
6173 );
6174 assert_eq!(
6175 format_space_default_response(Some("team"), false),
6176 "default space: team"
6177 );
6178 assert_eq!(
6179 format_space_current_response(Some(("team", "default")), false),
6180 "active space: team (default)"
6181 );
6182 }
6183
6184 #[test]
6185 fn colored_space_acknowledgements_mark_identities() {
6186 assert_eq!(
6187 format_space_add_response("work", true),
6188 "space added: \x1b[34mwork\x1b[0m"
6189 );
6190 assert_eq!(
6191 format_space_describe_response("work", true),
6192 "space description updated: \x1b[34mwork\x1b[0m"
6193 );
6194 assert_eq!(
6195 format_space_rename_response("work", "team", true),
6196 "space renamed: \x1b[34mwork\x1b[0m -> \x1b[34mteam\x1b[0m"
6197 );
6198 assert_eq!(
6199 format_space_remove_response("team", true),
6200 "removed: space \x1b[34mteam\x1b[0m"
6201 );
6202 assert_eq!(
6203 format_space_remove_default_response(true),
6204 "removed: space \x1b[34mdefault\x1b[0m\ndefault space: \x1b[33mcleared\x1b[0m"
6205 );
6206 assert_eq!(
6207 format_space_default_response(Some("team"), true),
6208 "default space: \x1b[34mteam\x1b[0m"
6209 );
6210 assert_eq!(
6211 format_space_current_response(Some(("team", "default")), true),
6212 "active space: \x1b[34mteam\x1b[0m (\x1b[90mdefault\x1b[0m)"
6213 );
6214 }
6215
6216 #[test]
6217 fn collection_acknowledgements_remain_plain_without_color() {
6218 assert_eq!(
6219 format_collection_describe_response("docs", false),
6220 "collection description updated: docs"
6221 );
6222 assert_eq!(
6223 format_collection_rename_response("docs", "notes", false),
6224 "collection renamed: docs -> notes"
6225 );
6226 assert_eq!(
6227 format_collection_remove_response("work", "notes", false),
6228 "removed: collection notes\nspace: work"
6229 );
6230 }
6231
6232 #[test]
6233 fn colored_collection_acknowledgements_mark_identities() {
6234 assert_eq!(
6235 format_collection_describe_response("docs", true),
6236 "collection description updated: \x1b[34mdocs\x1b[0m"
6237 );
6238 assert_eq!(
6239 format_collection_rename_response("docs", "notes", true),
6240 "collection renamed: \x1b[34mdocs\x1b[0m -> \x1b[34mnotes\x1b[0m"
6241 );
6242 assert_eq!(
6243 format_collection_remove_response("work", "notes", true),
6244 "removed: collection \x1b[34mnotes\x1b[0m\nspace: \x1b[34mwork\x1b[0m"
6245 );
6246 }
6247
6248 #[test]
6249 fn space_add_with_directories_reports_registration_without_indexing() {
6250 with_isolated_xdg_dirs(|| {
6251 let root = tempdir().expect("create collection root");
6252 let engine = Engine::new(None).expect("create engine");
6253 let mut adapter = CliAdapter::new(engine);
6254
6255 let work_path = new_collection_dir(root.path(), "work-api");
6256 let notes_path = new_collection_dir(root.path(), "work-notes");
6257
6258 let output = adapter
6259 .space_add("work", Some("work docs"), false, &[work_path, notes_path])
6260 .expect("add space with directories");
6261
6262 assert!(output.contains("space added: work"));
6263 assert!(output.contains("description: work docs"));
6264 assert!(output.contains("collections:"));
6265 assert!(output.contains("- 2 registered"));
6266 assert!(output.contains("indexing:"));
6267 assert!(output.contains("- skipped (collections registered only)"));
6268 assert!(output.contains("next:"));
6269 assert!(output.contains("- kbolt --space work update"));
6270 });
6271 }
6272
6273 #[test]
6274 fn space_add_without_directories_reports_created_space() {
6275 with_isolated_xdg_dirs(|| {
6276 let engine = Engine::new(None).expect("create engine");
6277 let mut adapter = CliAdapter::new(engine);
6278
6279 let output = adapter
6280 .space_add("work", Some("work docs"), false, &[])
6281 .expect("add space");
6282
6283 assert!(
6284 output.starts_with("space added: work\n"),
6285 "unexpected output:\n{output}"
6286 );
6287 assert!(output.contains("description: work docs"));
6288 assert!(!output.contains("next:"), "unexpected output:\n{output}");
6289 });
6290 }
6291
6292 #[test]
6293 fn space_add_with_partial_directory_failure_keeps_indexing_state_visible() {
6294 with_isolated_xdg_dirs(|| {
6295 let root = tempdir().expect("create collection root");
6296 let engine = Engine::new(None).expect("create engine");
6297 let mut adapter = CliAdapter::new(engine);
6298
6299 let valid_path = new_collection_dir(root.path(), "work-api");
6300 let missing_path = root.path().join("missing-docs");
6301
6302 let output = adapter
6303 .space_add("work", None, false, &[valid_path, missing_path.clone()])
6304 .expect("add space with partial failure");
6305
6306 assert!(output.contains("space added: work"));
6307 assert!(output.contains("collections:"));
6308 assert!(output.contains("- 1 registered"));
6309 assert!(output.contains("failed:"));
6310 assert!(output.contains("- 1 collection(s)"));
6311 assert!(output.contains(&missing_path.display().to_string()));
6312 assert!(output.contains("indexing:"));
6313 assert!(output.contains("- skipped (collections registered only)"));
6314 assert!(output.contains("- kbolt --space work update"));
6315 });
6316 }
6317
6318 #[test]
6319 fn space_list_and_info_use_sectioned_summaries() {
6320 with_isolated_xdg_dirs(|| {
6321 let engine = Engine::new(None).expect("create engine");
6322 engine
6323 .add_space("work", Some("work docs"))
6324 .expect("add work");
6325 let adapter = CliAdapter::new(engine);
6326
6327 let list_output = adapter.space_list().expect("list spaces");
6328 assert!(list_output.contains("spaces:"));
6329 assert!(
6330 list_output.contains("- work: 0 collection(s), 0 document(s), 0 chunk(s)"),
6331 "unexpected output:\n{list_output}"
6332 );
6333 assert!(list_output.contains("description: work docs"));
6334
6335 let info_output = adapter.space_info("work").expect("space info");
6336 assert!(
6337 info_output.starts_with("space: work\n"),
6338 "unexpected output:\n{info_output}"
6339 );
6340 assert!(info_output.contains("description: work docs"));
6341 assert!(info_output.contains("contents:"));
6342 assert!(info_output.contains("- 0 collection(s)"));
6343 assert!(info_output.contains("timestamps:"));
6344 });
6345 }
6346
6347 #[test]
6348 fn format_schedule_add_response_renders_trigger_scope_and_backend() {
6349 let output = format_schedule_add_response(&ScheduleAddResponse {
6350 schedule: ScheduleDefinition {
6351 id: "s1".to_string(),
6352 trigger: ScheduleTrigger::Every {
6353 interval: ScheduleInterval {
6354 value: 30,
6355 unit: ScheduleIntervalUnit::Minutes,
6356 },
6357 },
6358 scope: ScheduleScope::All,
6359 },
6360 backend: ScheduleBackend::Launchd,
6361 });
6362
6363 assert_eq!(
6364 output,
6365 "schedule added: s1\n\ndetails:\n- trigger: every 30m\n- scope: all spaces\n- backend: launchd"
6366 );
6367 }
6368
6369 #[test]
6370 fn format_schedule_status_response_renders_entries_and_orphans() {
6371 let output = format_schedule_status_response(&ScheduleStatusResponse {
6372 schedules: vec![
6373 ScheduleStatusEntry {
6374 schedule: ScheduleDefinition {
6375 id: "s2".to_string(),
6376 trigger: ScheduleTrigger::Weekly {
6377 weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
6378 time: "15:00".to_string(),
6379 },
6380 scope: ScheduleScope::Collections {
6381 space: "work".to_string(),
6382 collections: vec!["api".to_string(), "docs".to_string()],
6383 },
6384 },
6385 backend: ScheduleBackend::Launchd,
6386 state: ScheduleState::Drifted,
6387 run_state: ScheduleRunState {
6388 last_started: Some("2026-03-07T20:00:00Z".to_string()),
6389 last_finished: Some("2026-03-07T20:00:05Z".to_string()),
6390 last_result: Some(ScheduleRunResult::SkippedLock),
6391 last_error: Some("lock was held".to_string()),
6392 },
6393 },
6394 ScheduleStatusEntry {
6395 schedule: ScheduleDefinition {
6396 id: "s3".to_string(),
6397 trigger: ScheduleTrigger::Daily {
6398 time: "06:30".to_string(),
6399 },
6400 scope: ScheduleScope::Space {
6401 space: "research".to_string(),
6402 },
6403 },
6404 backend: ScheduleBackend::SystemdUser,
6405 state: ScheduleState::TargetMissing,
6406 run_state: ScheduleRunState {
6407 last_started: None,
6408 last_finished: None,
6409 last_result: Some(ScheduleRunResult::Failed),
6410 last_error: None,
6411 },
6412 },
6413 ],
6414 orphans: vec![ScheduleOrphan {
6415 id: "s9".to_string(),
6416 backend: ScheduleBackend::Launchd,
6417 }],
6418 });
6419
6420 assert!(output.contains("schedules:\n- s2"));
6421 assert!(output.contains(" trigger: mon, fri at 3:00 PM"));
6422 assert!(output.contains(" scope: work/api, work/docs"));
6423 assert!(output.contains(" backend: launchd"));
6424 assert!(output.contains(" state: drifted"));
6425 assert!(output.contains(" last started: 2026-03-07T20:00:00Z"));
6426 assert!(output.contains(" last finished: 2026-03-07T20:00:05Z"));
6427 assert!(output.contains(" last result: skipped lock"));
6428 assert!(output.contains(" last error: lock was held"));
6429 assert!(output.contains("- s3"));
6430 assert!(output.contains(" trigger: daily at 6:30 AM"));
6431 assert!(output.contains(" scope: space research"));
6432 assert!(output.contains(" backend: systemd-user"));
6433 assert!(output.contains(" state: target missing"));
6434 assert!(output.contains(" last started: never"));
6435 assert!(output.contains(" last finished: never"));
6436 assert!(output.contains(" last result: failed"));
6437 assert!(output.contains("orphans:\n- s9 (launchd)"));
6438 }
6439
6440 #[test]
6441 fn colored_schedule_status_marks_problem_states_by_severity() {
6442 let output = format_schedule_status_response_color(
6443 &ScheduleStatusResponse {
6444 schedules: vec![
6445 ScheduleStatusEntry {
6446 schedule: ScheduleDefinition {
6447 id: "s2".to_string(),
6448 trigger: ScheduleTrigger::Weekly {
6449 weekdays: vec![ScheduleWeekday::Mon],
6450 time: "15:00".to_string(),
6451 },
6452 scope: ScheduleScope::Space {
6453 space: "work".to_string(),
6454 },
6455 },
6456 backend: ScheduleBackend::Launchd,
6457 state: ScheduleState::Drifted,
6458 run_state: ScheduleRunState {
6459 last_started: Some("2026-03-07T20:00:00Z".to_string()),
6460 last_finished: Some("2026-03-07T20:00:05Z".to_string()),
6461 last_result: Some(ScheduleRunResult::SkippedLock),
6462 last_error: Some("lock was held".to_string()),
6463 },
6464 },
6465 ScheduleStatusEntry {
6466 schedule: ScheduleDefinition {
6467 id: "s3".to_string(),
6468 trigger: ScheduleTrigger::Daily {
6469 time: "06:30".to_string(),
6470 },
6471 scope: ScheduleScope::Space {
6472 space: "research".to_string(),
6473 },
6474 },
6475 backend: ScheduleBackend::SystemdUser,
6476 state: ScheduleState::TargetMissing,
6477 run_state: ScheduleRunState {
6478 last_started: None,
6479 last_finished: None,
6480 last_result: Some(ScheduleRunResult::Failed),
6481 last_error: None,
6482 },
6483 },
6484 ],
6485 orphans: vec![ScheduleOrphan {
6486 id: "s9".to_string(),
6487 backend: ScheduleBackend::Launchd,
6488 }],
6489 },
6490 true,
6491 );
6492
6493 assert!(
6494 output.contains("state: \x1b[33mdrifted\x1b[0m"),
6495 "drifted schedules should be warning-colored:\n{output}"
6496 );
6497 assert!(
6498 output.contains("last result: \x1b[33mskipped lock\x1b[0m"),
6499 "skipped lock result should be warning-colored:\n{output}"
6500 );
6501 assert!(
6502 output.contains("last error: \x1b[31mlock was held\x1b[0m"),
6503 "last error should be error-colored:\n{output}"
6504 );
6505 assert!(
6506 output.contains("state: \x1b[33mtarget missing\x1b[0m"),
6507 "target missing schedules should be warning-colored:\n{output}"
6508 );
6509 assert!(
6510 output.contains("last result: \x1b[31mfailed\x1b[0m"),
6511 "failed result should be error-colored:\n{output}"
6512 );
6513 assert!(
6514 output.contains("\x1b[33ms9 (launchd)\x1b[0m"),
6515 "orphan backend artifact should be warning-colored:\n{output}"
6516 );
6517 }
6518
6519 #[test]
6520 fn format_schedule_remove_response_renders_removed_id() {
6521 let output = format_schedule_remove_response(
6522 &RemoveScheduleSelector::Id {
6523 id: "s1".to_string(),
6524 },
6525 &ScheduleRemoveResponse {
6526 removed_ids: vec!["s1".to_string()],
6527 },
6528 );
6529
6530 assert_eq!(output, "removed: schedule s1");
6531 }
6532
6533 #[test]
6534 fn format_schedule_remove_response_renders_all_scope() {
6535 let output = format_schedule_remove_response(
6536 &RemoveScheduleSelector::All,
6537 &ScheduleRemoveResponse {
6538 removed_ids: vec!["s1".to_string(), "s2".to_string()],
6539 },
6540 );
6541
6542 assert_eq!(output, "removed: all schedules\nscope: all spaces");
6543 }
6544
6545 #[test]
6546 fn format_schedule_remove_response_renders_space_scope() {
6547 let output = format_schedule_remove_response(
6548 &RemoveScheduleSelector::Scope {
6549 scope: ScheduleScope::Space {
6550 space: "uiux".to_string(),
6551 },
6552 },
6553 &ScheduleRemoveResponse {
6554 removed_ids: vec!["s1".to_string(), "s2".to_string()],
6555 },
6556 );
6557
6558 assert_eq!(output, "removed: schedules\nspace: uiux");
6559 }
6560
6561 #[test]
6562 fn format_schedule_remove_response_renders_collection_scope() {
6563 let output = format_schedule_remove_response(
6564 &RemoveScheduleSelector::Scope {
6565 scope: ScheduleScope::Collections {
6566 space: "uiux".to_string(),
6567 collections: vec!["docs".to_string(), "notes".to_string()],
6568 },
6569 },
6570 &ScheduleRemoveResponse {
6571 removed_ids: vec!["s1".to_string(), "s2".to_string()],
6572 },
6573 );
6574
6575 assert_eq!(
6576 output,
6577 "removed: schedules\nspace: uiux\ncollections: docs, notes"
6578 );
6579 }
6580
6581 #[test]
6582 fn format_schedule_remove_response_renders_empty_state() {
6583 let output = format_schedule_remove_response(
6584 &RemoveScheduleSelector::All,
6585 &ScheduleRemoveResponse {
6586 removed_ids: Vec::new(),
6587 },
6588 );
6589
6590 assert_eq!(output, "removed: no schedules\nscope: all spaces");
6591 }
6592
6593 #[test]
6594 fn colored_schedule_remove_response_marks_identities() {
6595 let output = format_schedule_remove_response_color(
6596 &RemoveScheduleSelector::Scope {
6597 scope: ScheduleScope::Collections {
6598 space: "uiux".to_string(),
6599 collections: vec!["docs".to_string(), "notes".to_string()],
6600 },
6601 },
6602 &ScheduleRemoveResponse {
6603 removed_ids: vec!["s1".to_string()],
6604 },
6605 true,
6606 );
6607
6608 assert_eq!(
6609 output,
6610 "removed: schedules\nspace: \x1b[34muiux\x1b[0m\ncollections: \x1b[34mdocs\x1b[0m, \x1b[34mnotes\x1b[0m"
6611 );
6612 }
6613
6614 fn make_update_report(errors: Vec<FileError>, failed_docs: usize) -> UpdateReport {
6615 UpdateReport {
6616 scanned_docs: 0,
6617 skipped_mtime_docs: 0,
6618 skipped_hash_docs: 0,
6619 added_docs: 0,
6620 updated_docs: 0,
6621 failed_docs,
6622 deactivated_docs: 0,
6623 reactivated_docs: 0,
6624 reaped_docs: 0,
6625 embedded_chunks: 0,
6626 decisions: Vec::new(),
6627 errors,
6628 elapsed_ms: 0,
6629 }
6630 }
6631
6632 fn make_file_error(path: &str) -> FileError {
6633 FileError {
6634 path: path.to_string(),
6635 error: "test failure".to_string(),
6636 }
6637 }
6638
6639 fn make_collection_info(space: &str, name: &str) -> CollectionInfo {
6640 CollectionInfo {
6641 name: name.to_string(),
6642 space: space.to_string(),
6643 path: PathBuf::from("/tmp/x"),
6644 description: None,
6645 extensions: None,
6646 document_count: 0,
6647 active_document_count: 0,
6648 chunk_count: 0,
6649 embedded_chunk_count: 0,
6650 created: "2026-04-18T00:00:00Z".to_string(),
6651 updated: "2026-04-18T00:00:00Z".to_string(),
6652 }
6653 }
6654
6655 #[test]
6656 fn append_update_error_lines_returns_true_when_truncated() {
6657 let report = make_update_report(
6658 vec![
6659 make_file_error("a.md"),
6660 make_file_error("b.md"),
6661 make_file_error("c.md"),
6662 make_file_error("d.md"),
6663 ],
6664 4,
6665 );
6666 let mut lines = Vec::new();
6667 let truncated = append_update_error_lines(&mut lines, &report, 3);
6668 assert!(truncated);
6669 assert!(lines.iter().any(|l| l == "- 1 more error(s)"));
6670 }
6671
6672 #[test]
6673 fn append_update_error_lines_returns_false_within_limit() {
6674 let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
6675 let mut lines = Vec::new();
6676 let truncated = append_update_error_lines(&mut lines, &report, 3);
6677 assert!(!truncated);
6678 assert!(!lines.iter().any(|l| l.contains("more error(s)")));
6679 }
6680
6681 #[test]
6682 fn format_update_report_emits_verbose_hint_on_truncation() {
6683 let report = make_update_report(
6684 vec![
6685 make_file_error("a.md"),
6686 make_file_error("b.md"),
6687 make_file_error("c.md"),
6688 make_file_error("d.md"),
6689 ],
6690 4,
6691 );
6692 let output = format_update_report(&report, false, false);
6693 assert!(
6694 output.contains("- 1 more error(s)"),
6695 "expected truncation summary: {output}"
6696 );
6697 assert!(
6698 output.contains("next:\n- run with --verbose for the full error list"),
6699 "expected verbose hint: {output}"
6700 );
6701 }
6702
6703 #[test]
6704 fn format_update_report_omits_verbose_hint_without_truncation() {
6705 let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
6706 let output = format_update_report(&report, false, false);
6707 assert!(
6708 !output.contains("more error(s)"),
6709 "expected no truncation: {output}"
6710 );
6711 assert!(
6712 !output.contains("--verbose"),
6713 "expected no verbose hint: {output}"
6714 );
6715 }
6716
6717 #[test]
6718 fn format_collection_add_indexing_upgrades_next_to_verbose_when_truncated() {
6719 let collection = make_collection_info("default", "notes");
6720 let report = make_update_report(
6721 vec![
6722 make_file_error("a.md"),
6723 make_file_error("b.md"),
6724 make_file_error("c.md"),
6725 make_file_error("d.md"),
6726 ],
6727 4,
6728 );
6729 let result = AddCollectionResult {
6730 collection,
6731 initial_indexing: InitialIndexingOutcome::Indexed(report),
6732 };
6733 let output = format_collection_add_result(&result);
6734 assert!(
6735 output.contains("kbolt --space default update --verbose --collection notes"),
6736 "expected --verbose in next command: {output}"
6737 );
6738 }
6739
6740 #[test]
6741 fn format_collection_add_indexing_omits_verbose_when_not_truncated() {
6742 let collection = make_collection_info("default", "notes");
6743 let report = make_update_report(vec![make_file_error("a.md"), make_file_error("b.md")], 2);
6744 let result = AddCollectionResult {
6745 collection,
6746 initial_indexing: InitialIndexingOutcome::Indexed(report),
6747 };
6748 let output = format_collection_add_result(&result);
6749 assert!(
6750 output.contains("kbolt --space default update --collection notes"),
6751 "expected plain update command: {output}"
6752 );
6753 assert!(
6754 !output.contains("--verbose"),
6755 "expected no verbose flag: {output}"
6756 );
6757 }
6758
6759 #[test]
6760 fn format_collection_add_indexing_emits_next_block_when_truncated_without_failed_docs() {
6761 let collection = make_collection_info("default", "notes");
6762 let report = make_update_report(
6763 vec![
6764 make_file_error("a.md"),
6765 make_file_error("b.md"),
6766 make_file_error("c.md"),
6767 make_file_error("d.md"),
6768 ],
6769 0, );
6771 let result = AddCollectionResult {
6772 collection,
6773 initial_indexing: InitialIndexingOutcome::Indexed(report),
6774 };
6775 let output = format_collection_add_result(&result);
6776 assert!(
6777 output.contains("next:"),
6778 "expected a next block despite failed_docs=0: {output}"
6779 );
6780 assert!(
6781 output.contains("kbolt --space default update --verbose --collection notes"),
6782 "expected --verbose update in next: {output}"
6783 );
6784 }
6785
6786 #[test]
6787 fn format_collection_add_indexing_shell_quotes_space_and_name_in_next() {
6788 let collection = make_collection_info("team notes", "cold docs");
6789 let report = make_update_report(
6790 vec![
6791 make_file_error("a.md"),
6792 make_file_error("b.md"),
6793 make_file_error("c.md"),
6794 make_file_error("d.md"),
6795 ],
6796 4,
6797 );
6798 let result = AddCollectionResult {
6799 collection,
6800 initial_indexing: InitialIndexingOutcome::Indexed(report),
6801 };
6802 let output = format_collection_add_result(&result);
6803 assert!(
6804 output.contains("kbolt --space 'team notes' update --verbose --collection 'cold docs'"),
6805 "expected space and name single-quoted: {output}"
6806 );
6807 }
6808
6809 #[test]
6810 fn space_add_note_shell_quotes_space_name_with_whitespace() {
6811 with_isolated_xdg_dirs(|| {
6812 let root = tempdir().expect("create collection root");
6813 let engine = Engine::new(None).expect("create engine");
6814 let mut adapter = CliAdapter::new(engine);
6815
6816 let dir = new_collection_dir(root.path(), "some-collection");
6817 let output = adapter
6818 .space_add("team notes", None, false, &[dir])
6819 .expect("add space with directories");
6820
6821 assert!(
6822 output.contains("- kbolt --space 'team notes' update"),
6823 "expected quoted space in registration note: {output}"
6824 );
6825 });
6826 }
6827
6828 #[test]
6829 fn format_collection_add_result_skipped_shell_quotes_space_and_name() {
6830 let collection = make_collection_info("team notes", "cold docs");
6831 let result = AddCollectionResult {
6832 collection,
6833 initial_indexing: InitialIndexingOutcome::Skipped,
6834 };
6835 let output = format_collection_add_result(&result);
6836 assert!(
6837 output.contains("kbolt --space 'team notes' update --collection 'cold docs'"),
6838 "expected quoted space and name in next block: {output}"
6839 );
6840 }
6841
6842 #[test]
6843 fn format_collection_add_block_space_dense_repair_quotes_space_name() {
6844 let collection = make_collection_info("default", "notes");
6845 let result = AddCollectionResult {
6846 collection,
6847 initial_indexing: InitialIndexingOutcome::Blocked(
6848 InitialIndexingBlock::SpaceDenseRepairRequired {
6849 space: "team notes".to_string(),
6850 reason: "dimension mismatch".to_string(),
6851 },
6852 ),
6853 };
6854 let output = format_collection_add_result(&result);
6855 assert!(
6856 output.contains("kbolt --space 'team notes' update"),
6857 "expected quoted space in repair-required command: {output}"
6858 );
6859 }
6860
6861 #[test]
6862 fn format_collection_add_block_model_not_available_quotes_space_and_name() {
6863 let collection = make_collection_info("team notes", "cold docs");
6864 let result = AddCollectionResult {
6865 collection,
6866 initial_indexing: InitialIndexingOutcome::Blocked(
6867 InitialIndexingBlock::ModelNotAvailable {
6868 name: "embedder".to_string(),
6869 },
6870 ),
6871 };
6872 let output = format_collection_add_result(&result);
6873 assert!(
6874 output.contains("then run: kbolt --space 'team notes' update --collection 'cold docs'"),
6875 "expected quoted space and name in model-unavailable command: {output}"
6876 );
6877 }
6878
6879 #[test]
6880 fn format_eval_import_report_shell_quotes_space_collection_and_paths() {
6881 let report = EvalImportReport {
6882 dataset: "fiqa".to_string(),
6883 source: "/tmp/fiqa".to_string(),
6884 output_dir: "/tmp/out".to_string(),
6885 corpus_dir: "/Users/me/My Data/corpus".to_string(),
6886 manifest_path: "/Users/me/My Data/eval.toml".to_string(),
6887 default_space: "bench space".to_string(),
6888 collection: "fiqa docs".to_string(),
6889 document_count: 0,
6890 query_count: 0,
6891 judgment_count: 0,
6892 };
6893 let output = format_eval_import_report(&report);
6894 assert!(
6895 output.contains("kbolt space add 'bench space'"),
6896 "expected quoted space in create command: {output}"
6897 );
6898 assert!(
6899 output.contains("kbolt --space 'bench space' collection add '/Users/me/My Data/corpus' --name 'fiqa docs' --no-index"),
6900 "expected quoted args in register command: {output}"
6901 );
6902 assert!(
6903 output.contains("kbolt --space 'bench space' update --collection 'fiqa docs'"),
6904 "expected quoted args in index command: {output}"
6905 );
6906 assert!(
6907 output.contains("kbolt eval run --file '/Users/me/My Data/eval.toml'"),
6908 "expected quoted manifest path in eval run command: {output}"
6909 );
6910 }
6911
6912 #[test]
6913 fn colored_eval_import_report_marks_paths_and_command_segments() {
6914 let report = EvalImportReport {
6915 dataset: "fiqa".to_string(),
6916 source: "/tmp/fiqa".to_string(),
6917 output_dir: "/tmp/out".to_string(),
6918 corpus_dir: "/Users/me/My Data/corpus".to_string(),
6919 manifest_path: "/Users/me/My Data/eval.toml".to_string(),
6920 default_space: "bench space".to_string(),
6921 collection: "fiqa docs".to_string(),
6922 document_count: 0,
6923 query_count: 0,
6924 judgment_count: 0,
6925 };
6926 let output = format_eval_import_report_color(&report, true);
6927
6928 assert!(output.contains("source: \x1b[34m/tmp/fiqa\x1b[0m"));
6929 assert!(output.contains(
6930 "create the benchmark space if needed: \x1b[32mkbolt space add 'bench space'\x1b[0m"
6931 ));
6932 assert!(output.contains(
6933 "run eval: \x1b[32mkbolt eval run --file '/Users/me/My Data/eval.toml'\x1b[0m"
6934 ));
6935 assert!(!output.contains("\x1b[32mcreate the benchmark space if needed"));
6936 }
6937}