Skip to main content

kbolt_cli/
lib.rs

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