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