M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,680 @@
mod ai_provider;
mod file_provider;
mod history_provider;
mod path_provider;
mod shell_token;
use agent_client_protocol as acp;
use serde::{Deserialize, Serialize};
use super::{ExtResult, parse_params, to_raw_response};
use crate::agent::MvpAgent;
pub(crate) use file_provider::FilePathProvider;
pub(crate) use history_provider::HistoryProvider;
pub(crate) use path_provider::PathProvider;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SuggestRequest {
text: String,
cursor: usize,
cwd: String,
limit: usize,
generation: u64,
#[serde(default)]
include_ai: bool,
#[serde(default)]
ai_model: Option<String>,
#[serde(default)]
session_id: Option<String>,
/// Deterministic Tab mode: run only the token providers (path/file).
/// A history/AI row would make the set mixed — killing the pager's
/// insta-accept/LCP semantics — and reparse history per keystroke.
#[serde(default)]
token_only: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SuggestResponse {
ghost: Option<GhostSuggestion>,
completions: Vec<CompletionItem>,
generation: u64,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GhostSuggestion {
full_text: String,
suffix: String,
source: String,
}
/// One completion row. Wire-compat contract (leader mode and the cloud
/// bridge mix shell/pager versions):
/// - `insert_text` is ALWAYS a safe whole-line replacement — range-unaware
/// pagers `set_text` it, so it must never be a bare token.
/// - `replace_range` + `token_text` are the additive token-in-place upgrade:
/// byte offsets `[start, end)` into the request `text` and the text that
/// replaces that span. Range-aware pagers use them as an ATOMIC pair —
/// a range without `token_text` (history/AI whole-line rows, where
/// `insert_text` doubles as the span replacement) degrades to the
/// equivalent whole-line accept.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CompletionItem {
display: String,
description: String,
insert_text: String,
source: String,
priority: i32,
#[serde(skip_serializing_if = "Option::is_none")]
replace_range: Option<(usize, usize)>,
#[serde(skip_serializing_if = "Option::is_none")]
token_text: Option<String>,
/// The provider capped its scan/result set: the row set may be
/// incomplete, so range-aware pagers keep dropdown-only semantics
/// (absent = `false` for older shells).
#[serde(skip_serializing_if = "std::ops::Not::not")]
truncated: bool,
}
pub(crate) struct SuggestContext {
text: String,
cursor: usize,
cwd: String,
}
impl SuggestContext {
fn new(text: String, cursor: usize, cwd: String) -> Self {
let mut cursor = cursor.min(text.len());
while cursor > 0 && !text.is_char_boundary(cursor) {
cursor -= 1;
}
Self { text, cursor, cwd }
}
pub(crate) fn prefix(&self) -> &str {
&self.text[..self.cursor]
}
}
#[derive(Debug, Clone)]
pub(crate) struct RankedSuggestion {
pub(crate) display: String,
pub(crate) description: String,
/// Whole-line replacement (see the [`CompletionItem`] compat contract).
pub(crate) insert_text: String,
pub(crate) source: SuggestionSource,
pub(crate) priority: i32,
pub(crate) is_ghost_candidate: bool,
/// Request-text byte range the completion targets (token for path/file,
/// whole line for history/AI); `None` keeps whole-line-only semantics.
pub(crate) replace_range: Option<(usize, usize)>,
/// Replacement for `replace_range` when it differs from `insert_text`.
pub(crate) token_text: Option<String>,
/// Provider capped its scan/results — a hidden row could disprove a
/// sole match or an LCP.
pub(crate) truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SuggestionSource {
History,
Path,
File,
AI,
}
impl SuggestionSource {
fn as_str(self) -> &'static str {
match self {
Self::History => "history",
Self::Path => "path",
Self::File => "file",
Self::AI => "ai",
}
}
}
impl From<RankedSuggestion> for CompletionItem {
fn from(s: RankedSuggestion) -> Self {
Self {
display: s.display,
description: s.description,
insert_text: s.insert_text,
source: s.source.as_str().to_owned(),
priority: s.priority,
replace_range: s.replace_range,
token_text: s.token_text,
truncated: s.truncated,
}
}
}
/// Mark whole-line suggestions (history/AI carry the full command as
/// `insert_text`) as replacing the entire request text.
fn stamp_whole_line_range(results: &mut [RankedSuggestion], text_len: usize) {
results
.iter_mut()
.for_each(|s| s.replace_range = Some((0, text_len)));
}
/// Convert token-valued suggestions (path/file build `insert_text` as the
/// token replacing `range`) into the wire pair: the token moves to
/// `token_text` and `insert_text` becomes the full line with the token
/// spliced in — the shape range-unaware pagers can safely `set_text`.
fn splice_token_into_line(results: &mut [RankedSuggestion], text: &str, range: (usize, usize)) {
for s in results {
let token = std::mem::take(&mut s.insert_text);
s.insert_text = format!("{}{}{}", &text[..range.0], token, &text[range.1..]);
s.token_text = Some(token);
}
}
#[tracing::instrument(skip_all, fields(method = %args.method))]
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
match args.method.as_ref() {
"x.ai/suggest" => handle_suggest(agent, args).await,
"x.ai/suggestPrompt" => handle_suggest_prompt(agent, args).await,
_ => Err(acp::Error::method_not_found()),
}
}
/// Request/response for `x.ai/suggestPrompt` — predict the user's likely next
/// prompt after a completed turn (tab-autocomplete ghost text).
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SuggestPromptRequest {
/// Client-side generation counter, echoed back so stale responses can be
/// discarded (the client may have started a newer turn meanwhile).
generation: u64,
#[serde(default)]
session_id: Option<String>,
/// Client hint for the suggestion model (the pager sends its env
/// override, or `grok-build-0.1` when its catalog offers it). One tier
/// of the shell-side resolution in
/// `prompt_suggest::effective_suggest_model`: env > config.toml > remote
/// > this hint > `grok-build-0.1` default, catalog-guarded (a
/// non-sampleable effective model skips the request; the session model
/// is never used).
#[serde(default)]
model: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SuggestPromptResponse {
suggestion: Option<String>,
generation: u64,
}
/// Upper bound on the suggestion round-trip. Turn-end prediction is not
/// latency-critical (the user is reading the agent's reply — the idle window
/// after a turn is typically long), but a hung call must not pin the oneshot
/// forever. Reasoning models (e.g. `grok-build`) can take ~30s on a cold
/// cache; a late suggestion is still useful (the pager's generation guard
/// and empty-prompt gating discard it if the user moved on).
const SUGGEST_PROMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
async fn handle_suggest_prompt(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req: SuggestPromptRequest = parse_params(args)?;
let generation = req.generation;
let suggestion = match find_session(agent, req.session_id.as_deref()) {
Some(handle) => {
let (tx, rx) = tokio::sync::oneshot::channel();
let cmd = crate::session::commands::SessionCommand::SuggestPrompt {
model_override: req.model,
respond_to: tx,
};
if handle.cmd_tx.send(cmd).is_err() {
tracing::debug!("suggestPrompt: session command channel closed");
None
} else {
match tokio::time::timeout(SUGGEST_PROMPT_TIMEOUT, rx).await {
Ok(Ok(suggestion)) => suggestion,
Ok(Err(_)) => {
tracing::debug!("suggestPrompt: responder dropped");
None
}
Err(_) => {
tracing::debug!("suggestPrompt: timed out");
None
}
}
}
}
None => {
tracing::debug!(session_id = ?req.session_id, "suggestPrompt: session not found");
None
}
};
to_raw_response(&SuggestPromptResponse {
suggestion,
generation,
})
}
async fn handle_suggest(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
let req: SuggestRequest = parse_params(args)?;
let limit = req.limit;
let generation = req.generation;
let include_ai = req.include_ai;
let token_only = req.token_only;
let SuggestRequest {
text,
cursor,
cwd,
ai_model,
session_id,
..
} = req;
let ctx = SuggestContext::new(text, cursor, cwd);
let (history_results, path_results, file_results) = tokio::join!(
async {
if token_only {
Vec::new()
} else {
HistoryProvider.suggest(&ctx).await
}
},
PathProvider.suggest(&ctx),
FilePathProvider.suggest(&ctx),
);
let mut ai_results =
if include_ai && !token_only && !should_skip_ai(&history_results, ctx.prefix()) {
let session = find_session(agent, session_id.as_deref());
match session {
Some(handle) => {
ai_provider::suggest(&handle.cmd_tx, ctx.prefix(), &ctx.cwd, ai_model).await
}
None => Vec::new(),
}
} else {
Vec::new()
};
stamp_whole_line_range(&mut ai_results, ctx.text.len());
let (ghost, completions) = aggregate(
history_results,
path_results,
file_results,
ai_results,
ctx.prefix(),
limit,
);
to_raw_response(&SuggestResponse {
ghost,
completions,
generation,
})
}
fn find_session(
agent: &MvpAgent,
session_id: Option<&str>,
) -> Option<crate::session::handle::SessionHandle> {
let sessions = agent.sessions.borrow();
if let Some(id) = session_id {
sessions.get(&acp::SessionId::new(id)).cloned()
} else {
sessions.values().next().cloned()
}
}
fn aggregate(
history: Vec<RankedSuggestion>,
path: Vec<RankedSuggestion>,
file: Vec<RankedSuggestion>,
ai: Vec<RankedSuggestion>,
prefix: &str,
limit: usize,
) -> (Option<GhostSuggestion>, Vec<CompletionItem>) {
let mut all: Vec<RankedSuggestion> = history
.into_iter()
.chain(path)
.chain(file)
.chain(ai)
.collect();
// STABLE sort — load-bearing: providers pre-rank their items and ship
// them at one shared priority per response (the file provider's fuzzy
// tier/score/dirs-first order — see its `FILE_CMD_BOOST` doc), relying
// on equal-priority order surviving to the wire. Do not "optimize"
// into `sort_unstable_by`.
all.sort_by_key(|s| std::cmp::Reverse(s.priority));
let ghost = all.iter().find(|s| s.is_ghost_candidate).map(|s| {
let suffix = s.insert_text.strip_prefix(prefix).unwrap_or(&s.insert_text);
GhostSuggestion {
full_text: s.insert_text.clone(),
suffix: suffix.to_owned(),
source: s.source.as_str().to_owned(),
}
});
let completions = all
.into_iter()
.take(limit)
.map(CompletionItem::from)
.collect();
(ghost, completions)
}
/// Determines whether AI suggestions can be skipped based on history quality.
pub(crate) fn should_skip_ai(history_matches: &[RankedSuggestion], prefix: &str) -> bool {
if history_matches.is_empty() {
return false;
}
if history_matches[0].priority >= 30 {
return true;
}
!prefix.is_empty() && history_matches.len() >= 3
}
#[cfg(test)]
mod tests {
use super::*;
fn ranked(
priority: i32,
source: SuggestionSource,
ghost: bool,
text: &str,
) -> RankedSuggestion {
RankedSuggestion {
display: text.into(),
description: String::new(),
insert_text: text.into(),
source,
priority,
is_ghost_candidate: ghost,
replace_range: None,
token_text: None,
truncated: false,
}
}
// --- aggregate ---
#[test]
fn aggregate_sorts_by_descending_priority() {
let history = vec![
ranked(10, SuggestionSource::History, true, "git commit"),
ranked(5, SuggestionSource::History, false, "git checkout"),
];
let path = vec![ranked(0, SuggestionSource::Path, false, "git")];
let (_, completions) = aggregate(history, path, vec![], vec![], "git", 10);
assert_eq!(completions[0].priority, 10);
assert_eq!(completions[1].priority, 5);
assert_eq!(completions[2].priority, 0);
}
#[test]
fn aggregate_selects_ghost_with_correct_suffix() {
let history = vec![ranked(10, SuggestionSource::History, true, "git commit")];
let path = vec![ranked(0, SuggestionSource::Path, false, "git")];
let (ghost, _) = aggregate(history, path, vec![], vec![], "git", 10);
let ghost = ghost.unwrap();
assert_eq!(ghost.full_text, "git commit");
assert_eq!(ghost.suffix, " commit");
assert_eq!(ghost.source, "history");
}
#[test]
fn aggregate_no_ghost_without_candidate() {
let path = vec![
ranked(0, SuggestionSource::Path, false, "git"),
ranked(0, SuggestionSource::Path, false, "grep"),
];
let (ghost, _) = aggregate(vec![], path, vec![], vec![], "g", 10);
assert!(ghost.is_none());
}
#[test]
fn aggregate_respects_limit() {
let history: Vec<_> = (0..10)
.map(|i| {
ranked(
10 - i,
SuggestionSource::History,
i == 0,
&format!("cmd_{i}"),
)
})
.collect();
let (_, completions) = aggregate(history, vec![], vec![], vec![], "cmd", 3);
assert_eq!(completions.len(), 3);
}
#[test]
fn aggregate_ghost_suffix_for_exact_prefix() {
let history = vec![ranked(40, SuggestionSource::History, true, "ls")];
let (ghost, _) = aggregate(history, vec![], vec![], vec![], "ls", 10);
let ghost = ghost.unwrap();
assert_eq!(ghost.suffix, "");
}
#[test]
fn aggregate_includes_ai_results() {
let history = vec![ranked(10, SuggestionSource::History, true, "git commit")];
let ai = vec![ranked(
-10,
SuggestionSource::AI,
true,
"git commit --amend",
)];
let (_, completions) = aggregate(history, vec![], vec![], ai, "git", 10);
assert_eq!(completions.len(), 2);
assert_eq!(completions[0].source, "history");
assert_eq!(completions[1].source, "ai");
assert_eq!(completions[1].priority, -10);
}
#[test]
fn aggregate_ai_ghost_used_when_no_history_ghost() {
let ai = vec![ranked(
-10,
SuggestionSource::AI,
true,
"git commit --amend",
)];
let (ghost, _) = aggregate(vec![], vec![], vec![], ai, "git", 10);
let ghost = ghost.unwrap();
assert_eq!(ghost.source, "ai");
assert_eq!(ghost.suffix, " commit --amend");
}
// --- should_skip_ai ---
#[test]
fn skip_ai_returns_false_for_empty_history() {
assert!(!should_skip_ai(&[], "git"));
}
#[test]
fn skip_ai_on_exact_match() {
let m = vec![ranked(40, SuggestionSource::History, true, "git commit")];
assert!(should_skip_ai(&m, "git commit"));
}
#[test]
fn skip_ai_prefix_with_enough_matches() {
let m = vec![
ranked(10, SuggestionSource::History, true, "git commit"),
ranked(9, SuggestionSource::History, false, "git checkout"),
ranked(8, SuggestionSource::History, false, "git cherry-pick"),
];
assert!(should_skip_ai(&m, "git"));
}
#[test]
fn dont_skip_ai_prefix_with_few_matches() {
let m = vec![ranked(5, SuggestionSource::History, true, "git commit")];
assert!(!should_skip_ai(&m, "git"));
}
#[test]
fn skip_ai_many_matches_empty_prefix() {
let m = vec![
ranked(5, SuggestionSource::History, true, "a"),
ranked(4, SuggestionSource::History, false, "b"),
ranked(3, SuggestionSource::History, false, "c"),
];
// empty prefix + 3 matches: !prefix.is_empty() is false, len >= 3 is true → false AND true → false
assert!(!should_skip_ai(&m, ""));
}
#[test]
fn dont_skip_ai_few_matches_empty_prefix() {
let m = vec![
ranked(5, SuggestionSource::History, true, "a"),
ranked(4, SuggestionSource::History, false, "b"),
];
assert!(!should_skip_ai(&m, ""));
}
// --- context ---
#[test]
fn context_clamps_cursor_to_len() {
let ctx = SuggestContext::new("abc".into(), 100, "/tmp".into());
assert_eq!(ctx.cursor, 3);
assert_eq!(ctx.prefix(), "abc");
}
#[test]
fn context_adjusts_to_char_boundary() {
let text = "caf\u{00e9}"; // "cafe" with e-acute (2 bytes for e-acute)
assert_eq!(text.len(), 5);
let ctx = SuggestContext::new(text.into(), 4, "/tmp".into()); // middle of 2-byte e-acute
assert_eq!(ctx.prefix(), "caf");
}
#[test]
fn completion_item_serializes_replace_range_and_token_as_camel_case() {
let mut s = ranked(10, SuggestionSource::Path, false, "ls | grep");
s.replace_range = Some((5, 7));
s.token_text = Some("grep".into());
let json = serde_json::to_value(CompletionItem::from(s)).unwrap();
assert_eq!(json["replaceRange"], serde_json::json!([5, 7]));
assert_eq!(json["tokenText"], "grep");
// Whole-line compat field for range-unaware pagers.
assert_eq!(json["insertText"], "ls | grep");
}
#[test]
fn completion_item_omits_absent_replace_range_and_token() {
let json = serde_json::to_value(CompletionItem::from(ranked(
0,
SuggestionSource::Path,
false,
"grep",
)))
.unwrap();
assert!(json.get("replaceRange").is_none());
assert!(json.get("tokenText").is_none());
// Additive: `truncated` only serializes when set.
assert!(json.get("truncated").is_none());
}
#[test]
fn completion_item_serializes_truncated_when_set() {
let mut s = ranked(0, SuggestionSource::File, false, "notes.md");
s.truncated = true;
let json = serde_json::to_value(CompletionItem::from(s)).unwrap();
assert_eq!(json["truncated"], true);
}
#[test]
fn stamp_whole_line_range_covers_full_text() {
let mut results = vec![
ranked(10, SuggestionSource::History, true, "git commit"),
ranked(9, SuggestionSource::History, false, "git checkout"),
];
stamp_whole_line_range(&mut results, 5);
assert!(results.iter().all(|s| s.replace_range == Some((0, 5))));
// Whole-line items double as their own span replacement.
assert!(results.iter().all(|s| s.token_text.is_none()));
}
/// Token-valued suggestions become the wire pair: token in `token_text`,
/// `insert_text` rebuilt as the full line (safe for old pagers).
#[test]
fn splice_token_into_line_builds_compat_pair() {
let mut results = vec![ranked(0, SuggestionSource::Path, false, "grep")];
splice_token_into_line(&mut results, "ls | gr | wc -l", (5, 7));
assert_eq!(results[0].insert_text, "ls | grep | wc -l");
assert_eq!(results[0].token_text.as_deref(), Some("grep"));
}
/// Equal-priority items must keep their provider-internal order: the
/// file provider ships pre-ranked rows (fuzzy tier/score/dirs-first) at
/// ONE shared priority and its ranking reaches the wire only through
/// this sort's stability. Deliberately non-alphabetical, larger than
/// the small-slice insertion-sort threshold, and interleaved with a
/// second priority class so a `sort_unstable_by` swap turns this red.
#[test]
fn aggregate_preserves_provider_order_within_equal_priority() {
let file: Vec<_> = (0..32)
.map(|i| {
ranked(
2,
SuggestionSource::File,
false,
&format!("ranked_{:02}", 31 - i),
)
})
.collect();
let expected: Vec<String> = file.iter().map(|s| s.display.clone()).collect();
let path: Vec<_> = (0..32)
.map(|i| ranked(0, SuggestionSource::Path, false, &format!("exe_{i:02}")))
.collect();
let (_, completions) = aggregate(vec![], path, file, vec![], "r", 100);
let file_order: Vec<String> = completions
.iter()
.filter(|c| c.source == "file")
.map(|c| c.display.clone())
.collect();
assert_eq!(file_order, expected);
// The boosted file rows all sort ahead of the priority-0 path rows.
assert_eq!(
completions[..32]
.iter()
.filter(|c| c.source == "file")
.count(),
32
);
}
#[test]
fn completion_item_preserves_source_string() {
let item = CompletionItem::from(ranked(10, SuggestionSource::History, true, "cmd"));
assert_eq!(item.source, "history");
assert_eq!(item.priority, 10);
let item = CompletionItem::from(ranked(0, SuggestionSource::Path, false, "cmd"));
assert_eq!(item.source, "path");
let item = CompletionItem::from(ranked(5, SuggestionSource::File, false, "cmd"));
assert_eq!(item.source, "file");
}
#[test]
fn aggregate_includes_file_results() {
let history = vec![ranked(10, SuggestionSource::History, true, "cat ~/.bashrc")];
let file = vec![ranked(5, SuggestionSource::File, false, ".bashrc")];
let (_, completions) = aggregate(history, vec![], file, vec![], "cat", 10);
assert_eq!(completions.len(), 2);
assert_eq!(completions[0].priority, 10);
assert_eq!(completions[1].priority, 5);
assert_eq!(completions[1].source, "file");
}
}