use std::borrow::Cow;
use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use nu_ansi_term::{Color, Style};
use reedline::{
ColumnarMenu, Completer, DefaultHinter, Emacs, ExternalPrinter, FileBackedHistory, Highlighter,
KeyCode, KeyModifiers, MenuBuilder, Prompt, PromptEditMode, PromptHistorySearch,
PromptHistorySearchStatus, Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, StyledText,
Suggestion, ValidationResult, Validator, default_emacs_keybindings,
};
use crate::session::Session;
use crate::alias::Aliases;
use crate::style;
use crate::{BUILTINS, Surface};
const MENU_NAME: &str = "completion_menu";
pub const DEFAULT_COMPLETION_TIMEOUT: Duration = Duration::from_secs(2);
static COMPLETION_TIMEOUT_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn set_completion_timeout(timeout: Duration) {
COMPLETION_TIMEOUT_MS.store(timeout.as_millis() as u64, Ordering::Relaxed);
}
fn completion_timeout() -> Duration {
match COMPLETION_TIMEOUT_MS.load(Ordering::Relaxed) {
0 => DEFAULT_COMPLETION_TIMEOUT,
ms => Duration::from_millis(ms),
}
}
struct ReplValidator;
impl Validator for ReplValidator {
fn validate(&self, line: &str) -> ValidationResult {
if crate::command::is_incomplete(line) {
ValidationResult::Incomplete
} else {
ValidationResult::Complete
}
}
}
struct ReplPrompt {
server_name: String,
}
impl Prompt for ReplPrompt {
fn render_prompt_left(&self) -> Cow<'_, str> {
Cow::Borrowed(&self.server_name)
}
fn render_prompt_right(&self) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> {
Cow::Borrowed("> ")
}
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
Cow::Borrowed("::: ")
}
fn render_prompt_history_search_indicator(
&self,
history_search: PromptHistorySearch,
) -> Cow<'_, str> {
let status = match history_search.status {
PromptHistorySearchStatus::Passing => "",
PromptHistorySearchStatus::Failing => "failing ",
};
Cow::Owned(format!(
"({}reverse-search: {}) ",
status, history_search.term
))
}
}
pub struct ReplCompleter {
surface: Arc<RwLock<Surface>>,
session: Arc<Session>,
aliases: Arc<RwLock<Aliases>>,
runtime: tokio::runtime::Handle,
}
pub(crate) fn resolve_ref<'a>(
root: &'a serde_json::Value,
schema: &'a serde_json::Value,
) -> &'a serde_json::Value {
let Some(reference) = schema.get("$ref").and_then(|r| r.as_str()) else {
return schema;
};
let Some(path) = reference.strip_prefix("#/") else {
return schema;
};
let mut current = root;
for segment in path.split('/') {
let segment = segment.replace("~1", "/").replace("~0", "~");
match current.get(&segment) {
Some(next) => current = next,
None => return schema,
}
}
current
}
fn suggestion(value: impl Into<String>, description: Option<String>, span: Span) -> Suggestion {
Suggestion {
value: style::sanitize(&value.into()).into_owned(),
display_override: None,
description: description.map(|d| style::sanitize(&d).into_owned()),
style: None,
extra: None,
span,
append_whitespace: false,
match_indices: None,
}
}
fn word_suggestion(
value: impl Into<String>,
description: Option<String>,
span: Span,
) -> Suggestion {
Suggestion {
append_whitespace: true,
..suggestion(value, description, span)
}
}
impl ReplCompleter {
pub fn new(
surface: Arc<RwLock<Surface>>,
session: Arc<Session>,
aliases: Arc<RwLock<Aliases>>,
runtime: tokio::runtime::Handle,
) -> Self {
Self {
surface,
session,
aliases,
runtime,
}
}
fn complete_prompt_arg_via_server(
&self,
prompt: &str,
arg: &str,
partial: &str,
) -> Vec<String> {
let client = self.session.client();
let (prompt, arg, partial) = (prompt.to_string(), arg.to_string(), partial.to_string());
self.runtime
.block_on(async move {
tokio::time::timeout(
completion_timeout(),
client.complete_prompt_arg(&prompt, &arg, &partial),
)
.await
})
.ok()
.and_then(|r| r.ok())
.map(|r| r.completion.values)
.unwrap_or_default()
}
fn complete_template_var_via_server(
&self,
uri_template: &str,
var: &str,
partial: &str,
) -> Vec<String> {
let client = self.session.client();
let (template, var, partial) = (
uri_template.to_string(),
var.to_string(),
partial.to_string(),
);
self.runtime
.block_on(async move {
tokio::time::timeout(
completion_timeout(),
client.complete_resource_uri(&template, &var, &partial),
)
.await
})
.ok()
.and_then(|r| r.ok())
.map(|r| r.completion.values)
.unwrap_or_default()
}
fn complete_resource_word(&self, surface: &Surface, word: &str, span: Span) -> Vec<Suggestion> {
let mut out = Vec::new();
for r in &surface.resources {
if r.uri.starts_with(word) {
out.push(word_suggestion(&r.uri, Some(r.name.clone()), span));
}
}
for t in &surface.templates {
if t.uri_template.starts_with(word) {
out.push(suggestion(&t.uri_template, Some(t.name.clone()), span));
}
let Some(open) = t.uri_template.find('{') else {
continue;
};
let Some(close_rel) = t.uri_template[open..].find('}') else {
continue;
};
let close = open + close_rel;
let static_prefix = &t.uri_template[..open];
if word.len() < static_prefix.len() || !word.starts_with(static_prefix) {
continue;
}
let var = &t.uri_template[open + 1..close];
let suffix = &t.uri_template[close + 1..];
let partial_value = &word[static_prefix.len()..];
for v in self.complete_template_var_via_server(&t.uri_template, var, partial_value) {
let mut full = format!("{static_prefix}{v}");
if !suffix.contains('{') {
full.push_str(suffix);
}
out.push(suggestion(full, Some(format!("{var} ({})", t.name)), span));
}
}
out
}
fn complete_tool_arg_word(
surface: &Surface,
tool_name: &str,
word: &str,
span: Span,
) -> Vec<Suggestion> {
let mut out = Vec::new();
let Some(tool) = surface.tools.iter().find(|t| t.name == tool_name) else {
return out;
};
let Some(props) = tool
.input_schema
.get("properties")
.and_then(|p| p.as_object())
else {
return out;
};
if let Some((arg_name, partial)) = word.split_once('=') {
if let Some(values) = props
.get(arg_name)
.map(|schema| resolve_ref(&tool.input_schema, schema))
.and_then(|s| s.get("enum").cloned())
.as_ref()
.and_then(|e| e.as_array())
{
for v in values {
if let Some(v) = v.as_str()
&& v.starts_with(partial)
{
out.push(word_suggestion(format!("{arg_name}={v}"), None, span));
}
}
}
return out;
}
let required: Vec<&str> = tool
.input_schema
.get("required")
.and_then(|r| r.as_array())
.map(|r| r.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
for (key, prop) in props {
if !key.starts_with(word) {
continue;
}
let target = resolve_ref(&tool.input_schema, prop);
let ty = target.get("type").and_then(|t| t.as_str()).unwrap_or("");
let desc = prop
.get("description")
.and_then(|d| d.as_str())
.unwrap_or("");
let req = if required.contains(&key.as_str()) {
"required "
} else {
""
};
let full = format!("{req}{ty} {desc}");
let full = full.trim();
let desc = (!full.is_empty()).then(|| full.to_string());
out.push(suggestion(format!("{key}="), desc, span));
}
out
}
fn complete_describe_word(surface: &Surface, word: &str, span: Span) -> Vec<Suggestion> {
let mut out = Vec::new();
for t in &surface.tools {
if t.name.starts_with(word) {
out.push(word_suggestion(&t.name, Some("tool".to_string()), span));
}
}
for p in &surface.prompts {
if p.name.starts_with(word) {
out.push(word_suggestion(&p.name, Some("prompt".to_string()), span));
}
}
for r in &surface.resources {
if r.uri.starts_with(word) {
out.push(word_suggestion(&r.uri, Some("resource".to_string()), span));
}
}
for t in &surface.templates {
if t.uri_template.starts_with(word) {
out.push(word_suggestion(
&t.uri_template,
Some("template".to_string()),
span,
));
}
}
out
}
}
impl Completer for ReplCompleter {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let head = &line[..pos];
let (word_start, word) = match head.rfind(char::is_whitespace) {
Some(i) => (i + 1, &head[i + 1..]),
None => (0, head),
};
let span = Span::new(word_start, pos);
let surface = self.surface.read().unwrap();
let mut out: Vec<Suggestion> = Vec::new();
let first = head.split_whitespace().next().unwrap_or("");
let completing_first = word_start == 0;
if completing_first {
for (name, desc) in BUILTINS {
if name.starts_with(word) {
out.push(word_suggestion(*name, Some(desc.to_string()), span));
}
}
for entry in self.aliases.read().unwrap().entries() {
if entry.name.starts_with(word) {
out.push(word_suggestion(
entry.name,
Some(format!("alias for `{}`", entry.expansion)),
span,
));
}
}
if word.starts_with('-') && "--full".starts_with(word) {
out.push(word_suggestion(
"--full",
Some("print every row, ignoring the window height".to_string()),
span,
));
}
for t in &surface.tools {
if t.name.starts_with(word) {
let tags = crate::tool_tags(t);
let description = match (t.description.as_deref(), tags.is_empty()) {
(_, false) => Some(format!(
"{}{}[{}]",
t.description.as_deref().unwrap_or(""),
if t.description.is_some() { " " } else { "" },
tags.join(" ")
)),
(description, true) => description.map(str::to_string),
};
out.push(word_suggestion(&t.name, description, span));
}
}
return out;
}
match first {
"find" if word.starts_with('-') => {
for (flag, description) in [
("-E", "treat the keyword as a regular expression"),
("-m", "cap the number of results"),
("--case-sensitive", "do not fold case"),
("--tools", "search tools only"),
("--prompts", "search prompts only"),
("--resources", "search resources only"),
("--templates", "search resource templates only"),
("--builtins", "search the REPL's own commands only"),
] {
if flag.starts_with(word) {
out.push(word_suggestion(flag, Some(description.to_string()), span));
}
}
}
"read" if word.starts_with('-') => {
for (flag, description) in [
(
"--out",
"write the content to a file instead of printing it",
),
("--force", "overwrite the file if it exists"),
] {
if flag.starts_with(word) {
out.push(word_suggestion(flag, Some(description.to_string()), span));
}
}
}
"read" | "subscribe" => {
out.extend(self.complete_resource_word(&surface, word, span));
}
"unsubscribe" => {
for uri in crate::subscribe::list() {
if uri.starts_with(word) {
out.push(word_suggestion(uri, None, span));
}
}
}
"loglevel" => {
for level in crate::LOG_LEVELS {
if level.starts_with(word) {
out.push(word_suggestion(*level, None, span));
}
}
}
"wire" => {
for state in ["on", "off"] {
if state.starts_with(word) {
out.push(word_suggestion(state, None, span));
}
}
}
"task" | "wait" | "cancel" if head.split_whitespace().count() >= 2 => {
let naming_task = head.split_whitespace().count() == 2
&& !head.ends_with(' ')
&& !word.is_empty();
if naming_task || head.split_whitespace().count() == 1 {
if "last".starts_with(word) {
out.push(word_suggestion(
"last",
Some("the most recently started task".to_string()),
span,
));
}
} else if first == "task" && "respond".starts_with(word) {
out.push(word_suggestion(
"respond",
Some("answer what the task is waiting for".to_string()),
span,
));
}
}
"describe" | "snapshot" => {
out.extend(Self::complete_describe_word(&surface, word, span));
}
"unalias" => {
for entry in self.aliases.read().unwrap().entries() {
if entry.name.starts_with(word) {
out.push(word_suggestion(
entry.name,
Some(format!("{} ({})", entry.expansion, entry.scope.label())),
span,
));
}
}
}
"prompt" => {
let words = head.split_whitespace().count();
let second_word = words == 2 && !head.ends_with(' ');
let naming_prompt = second_word || words == 1;
if naming_prompt {
for p in &surface.prompts {
if p.name.starts_with(word) {
out.push(word_suggestion(&p.name, p.description.clone(), span));
}
}
} else if let Some(prompt_name) = head.split_whitespace().nth(1) {
if let Some((arg_name, partial)) = word.split_once('=') {
for v in self.complete_prompt_arg_via_server(prompt_name, arg_name, partial)
{
out.push(word_suggestion(format!("{arg_name}={v}"), None, span));
}
} else if let Some(p) = surface.prompts.iter().find(|p| p.name == prompt_name) {
for a in &p.arguments {
if a.name.starts_with(word) {
let desc = match (&a.description, a.required) {
(Some(d), true) => Some(format!("(required) {d}")),
(Some(d), false) => Some(d.clone()),
(None, true) => Some("(required)".to_string()),
(None, false) => None,
};
out.push(suggestion(format!("{}=", a.name), desc, span));
}
}
}
}
}
"call" => {
for t in &surface.tools {
if t.name.starts_with(word) {
out.push(word_suggestion(&t.name, t.description.clone(), span));
}
}
}
"bench" => {
let words = head.split_whitespace().count();
let naming_tool = words == 1 || (words == 2 && !head.ends_with(' '));
if word.starts_with('-') {
for flag in ["--n", "--concurrency"] {
if flag.starts_with(word) {
out.push(word_suggestion(flag, None, span));
}
}
} else if naming_tool {
for t in &surface.tools {
if t.name.starts_with(word) {
out.push(word_suggestion(&t.name, t.description.clone(), span));
}
}
} else if let Some(tool_name) = head.split_whitespace().nth(1) {
out.extend(Self::complete_tool_arg_word(
&surface, tool_name, word, span,
));
}
}
tool_name => {
out.extend(Self::complete_tool_arg_word(
&surface, tool_name, word, span,
));
}
}
out
}
}
pub struct ReplHighlighter {
surface: Arc<RwLock<Surface>>,
aliases: Arc<RwLock<Aliases>>,
}
impl ReplHighlighter {
pub fn new(surface: Arc<RwLock<Surface>>, aliases: Arc<RwLock<Aliases>>) -> Self {
Self { surface, aliases }
}
fn command_style(&self, word: &str) -> Style {
if BUILTINS.iter().any(|(name, _)| *name == word) {
return Style::new().fg(Color::Cyan).bold();
}
let aliases = self.aliases.read().unwrap();
if aliases.lookup(word).is_some() {
return Style::new().fg(Color::Cyan).bold();
}
let surface = self.surface.read().unwrap();
if surface.tools.iter().any(|t| t.name == word) {
return Style::new().fg(Color::Green).bold();
}
let is_prefix = BUILTINS.iter().any(|(name, _)| name.starts_with(word))
|| aliases.entries().iter().any(|e| e.name.starts_with(word))
|| surface.tools.iter().any(|t| t.name.starts_with(word));
if is_prefix {
Style::new()
} else {
Style::new().fg(Color::Red)
}
}
}
fn value_style(raw: &str) -> Style {
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(serde_json::Value::Number(_)) => Style::new().fg(Color::Yellow),
Ok(serde_json::Value::Bool(_)) | Ok(serde_json::Value::Null) => {
Style::new().fg(Color::Purple)
}
Ok(serde_json::Value::String(_)) => Style::new().fg(Color::Green),
Ok(_) => Style::new().fg(Color::Green).dimmed(),
Err(_) => Style::new(),
}
}
impl Highlighter for ReplHighlighter {
fn highlight(&self, line: &str, _cursor: usize) -> StyledText {
let mut styled = StyledText::new();
if !style::colors_enabled() {
styled.push((Style::new(), line.to_string()));
return styled;
}
let mut seen_command = false;
let mut rest = line;
while !rest.is_empty() {
let token_start = match rest.find(|c: char| !c.is_whitespace()) {
Some(i) => i,
None => {
styled.push((Style::new(), rest.to_string()));
break;
}
};
if token_start > 0 {
styled.push((Style::new(), rest[..token_start].to_string()));
}
let token_end = rest[token_start..]
.find(char::is_whitespace)
.map(|i| token_start + i)
.unwrap_or(rest.len());
let token = &rest[token_start..token_end];
if !seen_command {
styled.push((self.command_style(token), token.to_string()));
seen_command = true;
} else if token == "&" {
styled.push((Style::new().fg(Color::Purple).bold(), token.to_string()));
} else if let Some((key, value)) = token.split_once('=') {
styled.push((Style::new().fg(Color::Cyan), format!("{key}=")));
styled.push((value_style(value), value.to_string()));
} else {
styled.push((value_style(token), token.to_string()));
}
rest = &rest[token_end..];
}
if line.is_empty() {
styled.push((Style::new(), String::new()));
}
styled
}
}
#[allow(clippy::too_many_arguments)]
pub fn spawn_readline_thread(
server_name: String,
surface: Arc<RwLock<Surface>>,
session: Arc<Session>,
aliases: Arc<RwLock<Aliases>>,
runtime: tokio::runtime::Handle,
line_tx: tokio::sync::mpsc::Sender<String>,
ack_rx: std::sync::mpsc::Receiver<()>,
at_prompt: Arc<AtomicBool>,
external_printer: ExternalPrinter<String>,
persist_history: bool,
history_capacity: usize,
) {
std::thread::spawn(move || {
if !std::io::stdin().is_terminal() {
run_piped(&line_tx, &ack_rx);
return;
}
run_interactive(
server_name,
surface,
session,
aliases,
runtime,
&line_tx,
&ack_rx,
at_prompt,
external_printer,
persist_history,
history_capacity,
);
});
}
pub const DEFAULT_HISTORY_CAPACITY: usize = 1000;
pub fn history_path() -> Option<std::path::PathBuf> {
let base = match std::env::var_os("XDG_STATE_HOME") {
Some(dir) if !dir.is_empty() => std::path::PathBuf::from(dir),
_ => {
let mut home = std::path::PathBuf::from(std::env::var_os("HOME")?);
home.push(".local");
home.push("state");
home
}
};
Some(base.join("mcp-repl").join("history"))
}
pub fn recent_history(limit: usize) -> Vec<String> {
let Some(path) = history_path() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(&path) else {
return Vec::new();
};
let lines: Vec<&str> = text
.lines()
.filter(|line| !line.trim().is_empty())
.collect();
lines
.iter()
.rev()
.take(limit)
.rev()
.map(|line| (*line).to_string())
.collect()
}
fn legacy_history_path() -> Option<std::path::PathBuf> {
let mut path = std::path::PathBuf::from(std::env::var_os("HOME")?);
path.push(".mcp-repl_history");
Some(path)
}
fn migrate_legacy_history(destination: &std::path::Path) {
if destination.exists() {
return;
}
let Some(legacy) = legacy_history_path() else {
return;
};
if !legacy.is_file() {
return;
}
if let Err(e) = crate::secure_file::create_parent_dir(destination) {
eprintln!("warning: could not create the history directory: {e}");
return;
}
match std::fs::rename(&legacy, destination) {
Ok(()) => eprintln!(
"note: moved command history to {} (it now follows the XDG state layout)",
destination.display()
),
Err(_) => match std::fs::copy(&legacy, destination) {
Ok(_) => eprintln!(
"note: copied command history to {}; the old {} can be deleted",
destination.display(),
legacy.display()
),
Err(e) => eprintln!("warning: could not migrate command history: {e}"),
},
}
}
fn run_piped(line_tx: &tokio::sync::mpsc::Sender<String>, ack_rx: &std::sync::mpsc::Receiver<()>) {
let stdin = std::io::stdin();
let mut buf = String::new();
loop {
buf.clear();
let read = {
let mut lock = stdin.lock();
std::io::BufRead::read_line(&mut lock, &mut buf)
};
match read {
Ok(0) | Err(_) => {
let _ = line_tx.blocking_send("quit".to_string());
break;
}
Ok(_) => {
if line_tx
.blocking_send(buf.trim_end_matches('\n').to_string())
.is_err()
|| ack_rx.recv().is_err()
{
break;
}
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_interactive(
server_name: String,
surface: Arc<RwLock<Surface>>,
session: Arc<Session>,
aliases: Arc<RwLock<Aliases>>,
runtime: tokio::runtime::Handle,
line_tx: &tokio::sync::mpsc::Sender<String>,
ack_rx: &std::sync::mpsc::Receiver<()>,
at_prompt: Arc<AtomicBool>,
external_printer: ExternalPrinter<String>,
persist_history: bool,
history_capacity: usize,
) {
let completer = ReplCompleter::new(surface.clone(), session, aliases.clone(), runtime);
let highlighter = ReplHighlighter::new(surface, aliases);
let menu = ColumnarMenu::default().with_name(MENU_NAME);
let mut keybindings = default_emacs_keybindings();
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Tab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu(MENU_NAME.to_string()),
ReedlineEvent::MenuNext,
]),
);
let mut editor = Reedline::create()
.with_validator(Box::new(ReplValidator))
.with_completer(Box::new(completer))
.with_menu(ReedlineMenu::EngineCompleter(Box::new(menu)))
.with_edit_mode(Box::new(Emacs::new(keybindings)))
.with_highlighter(Box::new(highlighter))
.with_hinter(Box::new(
DefaultHinter::default().with_style(Style::new().fg(Color::DarkGray)),
))
.with_external_printer(external_printer)
.with_ansi_colors(style::colors_enabled());
if persist_history && let Some(path) = history_path() {
migrate_legacy_history(&path);
if let Err(e) = crate::secure_file::ensure_owner_only(&path) {
eprintln!("warning: could not secure the history file: {e}");
}
match FileBackedHistory::with_file(history_capacity, path) {
Ok(history) => editor = editor.with_history(Box::new(history)),
Err(e) => eprintln!("warning: command history disabled: {e}"),
}
}
let prompt = ReplPrompt { server_name };
loop {
at_prompt.store(true, Ordering::SeqCst);
let sig = editor.read_line(&prompt);
at_prompt.store(false, Ordering::SeqCst);
match sig {
Ok(Signal::Success(line)) => {
let _ = editor.sync_history();
if line_tx.blocking_send(line).is_err() {
break;
}
if ack_rx.recv().is_err() {
break;
}
}
Ok(Signal::CtrlC) => continue,
_ => {
let _ = line_tx.blocking_send("quit".to_string());
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn schema_with_defs() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"to": {"$ref": "#/$defs/Scale", "description": "Target scale"},
"value": {"type": "number"},
},
"$defs": {
"Scale": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
})
}
fn with_state_home<T>(dir: &std::path::Path, body: impl FnOnce() -> T) -> T {
static GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _held = GUARD.lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var_os("XDG_STATE_HOME");
unsafe { std::env::set_var("XDG_STATE_HOME", dir) };
let out = body();
match previous {
Some(value) => unsafe { std::env::set_var("XDG_STATE_HOME", value) },
None => unsafe { std::env::remove_var("XDG_STATE_HOME") },
}
out
}
#[test]
fn history_follows_the_xdg_state_layout() {
let dir = tempfile::tempdir().unwrap();
let path = with_state_home(dir.path(), history_path).expect("a path");
assert_eq!(path, dir.path().join("mcp-repl").join("history"));
assert!(!path.to_string_lossy().contains(".mcp-repl_history"));
}
#[test]
fn recent_history_returns_the_newest_entries_in_order() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("mcp-repl").join("history");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(
&file,
"first
second
third
fourth
",
)
.unwrap();
let all = with_state_home(dir.path(), || recent_history(10));
assert_eq!(all, vec!["first", "second", "third", "fourth"]);
let tail = with_state_home(dir.path(), || recent_history(2));
assert_eq!(tail, vec!["third", "fourth"]);
}
#[test]
fn no_history_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(with_state_home(dir.path(), || recent_history(10)).is_empty());
}
#[test]
fn migration_does_not_overwrite_an_existing_history() {
let dir = tempfile::tempdir().unwrap();
let destination = dir.path().join("history");
std::fs::write(
&destination,
"already here
",
)
.unwrap();
migrate_legacy_history(&destination);
assert_eq!(
std::fs::read_to_string(&destination).unwrap(),
"already here\n"
);
}
#[test]
fn a_local_ref_resolves_to_its_definition() {
let root = schema_with_defs();
let property = &root["properties"]["to"];
let resolved = resolve_ref(&root, property);
assert_eq!(resolved["type"], "string");
assert_eq!(resolved["enum"][0], "celsius");
}
#[test]
fn a_schema_without_a_ref_is_returned_unchanged() {
let root = schema_with_defs();
let property = &root["properties"]["value"];
assert_eq!(resolve_ref(&root, property)["type"], "number");
}
#[test]
fn an_unresolvable_ref_degrades_to_the_property_itself() {
let root = schema_with_defs();
for reference in [
serde_json::json!({"$ref": "#/$defs/Missing"}),
serde_json::json!({"$ref": "https://example.com/schema.json"}),
] {
assert_eq!(resolve_ref(&root, &reference), &reference);
}
}
#[test]
fn json_pointer_escapes_are_decoded() {
let root = serde_json::json!({
"$defs": {"a/b~c": {"type": "integer"}},
});
let reference = serde_json::json!({"$ref": "#/$defs/a~1b~0c"});
assert_eq!(resolve_ref(&root, &reference)["type"], "integer");
}
}