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