use std::path::Path;
use std::time::Duration;
use oino_ai::JsonValue;
use oino_models::auth::credential::{AuthCredential, OAuthCredential};
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::{AgentSession, lock};
use crate::extensions::api::CallKind;
const COMPLETION_BUDGET: Duration = Duration::from_millis(100);
const MAX_PATH_ITEMS: usize = 50;
const MAX_PATH_SCAN: usize = 20_000;
const SERVICE_TIMEOUT: Duration = Duration::from_secs(30);
pub const FRONTEND_CALLER: &str = "oino.frontend";
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum FrontendError {
#[error("{0}")]
NotFound(String),
#[error("{0}")]
Unsupported(String),
#[error("{0}")]
Failed(String),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RenderTarget {
CustomMessage,
CustomEntry,
#[default]
Tool,
TurnFooter,
}
impl RenderTarget {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::CustomMessage => "customMessage",
Self::CustomEntry => "customEntry",
Self::Tool => "tool",
Self::TurnFooter => "turnFooter",
}
}
#[must_use]
pub const fn spec_str(self) -> &'static str {
match self {
Self::CustomMessage => "customMessage",
Self::CustomEntry => "customEntry",
Self::Tool => "tool",
Self::TurnFooter => "turn-footer",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
match name {
"customMessage" => Some(Self::CustomMessage),
"customEntry" => Some(Self::CustomEntry),
"tool" => Some(Self::Tool),
"turnFooter" => Some(Self::TurnFooter),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RenderDensity {
#[default]
Simple,
Regular,
Advanced,
}
impl RenderDensity {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Simple => "simple",
Self::Regular => "regular",
Self::Advanced => "advanced",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
match name {
"simple" => Some(Self::Simple),
"regular" => Some(Self::Regular),
"advanced" => Some(Self::Advanced),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RenderWidth {
Narrow,
#[default]
Normal,
Wide,
}
impl RenderWidth {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Narrow => "narrow",
Self::Normal => "normal",
Self::Wide => "wide",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
match name {
"narrow" => Some(Self::Narrow),
"normal" => Some(Self::Normal),
"wide" => Some(Self::Wide),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RenderRequest {
pub target: RenderTarget,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_entry_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_index: Option<u64>,
#[serde(default)]
pub running: bool,
#[serde(default)]
pub density: RenderDensity,
#[serde(default)]
pub expanded: bool,
#[serde(default)]
pub width: RenderWidth,
#[serde(default)]
pub data: JsonValue,
}
impl RenderRequest {
#[must_use]
pub fn tool(key: impl Into<String>, entry_id: Option<String>, data: JsonValue) -> Self {
Self {
target: RenderTarget::Tool,
key: key.into(),
entry_id,
turn_entry_id: None,
turn_index: None,
running: false,
density: RenderDensity::Simple,
expanded: false,
width: RenderWidth::Normal,
data,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletionEntry {
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort_tier: Option<i64>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompletionGroup {
pub source: String,
#[serde(default)]
pub from: Option<usize>,
pub items: Vec<CompletionEntry>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EditorIntent {
Changed,
Cleared,
EmptyArrowDown,
Submit,
TokenBackspace,
}
impl EditorIntent {
#[must_use]
pub const fn consumable(self) -> bool {
matches!(self, Self::EmptyArrowDown | Self::TokenBackspace)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorEvent {
pub event_id: String,
pub intent: EditorIntent,
#[serde(default)]
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthProviderInfo {
pub id: String,
pub name: String,
pub oauth: bool,
pub configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stored: Option<String>,
pub env: Vec<String>,
}
fn byte_index(text: &str, chars: usize) -> usize {
text.char_indices()
.nth(chars)
.map_or(text.len(), |(i, _)| i)
}
fn fuzzy(candidate: &str, query: &str) -> bool {
let mut chars = candidate.chars().flat_map(char::to_lowercase);
query
.chars()
.flat_map(char::to_lowercase)
.all(|q| chars.any(|c| c == q))
}
fn path_candidates(cwd: &Path, query: &str) -> Vec<CompletionEntry> {
let (base, needle) = match query.rfind('/') {
Some(i) if query.starts_with("~/") || query.starts_with('/') => {
(Path::new(&query[..=i]).to_path_buf(), &query[i + 1..])
}
_ => (cwd.to_path_buf(), query),
};
let base = match base.strip_prefix("~/") {
Ok(rest) => oino_settings::paths::home_dir().map_or(base.clone(), |h| h.join(rest)),
Err(_) => base,
};
let absolute = base != cwd;
let mut found: Vec<(bool, String, bool)> = Vec::new();
let walker = ignore::WalkBuilder::new(&base)
.hidden(!needle.starts_with('.'))
.max_depth(Some(if absolute { 1 } else { 8 }))
.build();
for entry in walker.filter_map(Result::ok).take(MAX_PATH_SCAN) {
let Ok(relative) = entry.path().strip_prefix(&base) else {
continue;
};
let relative = relative.to_string_lossy().replace('\\', "/");
if relative.is_empty() || !fuzzy(&relative, needle) {
continue;
}
let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
let shown = if absolute {
format!("{}{relative}", &query[..query.len() - needle.len()])
} else {
relative.clone()
};
let prefix = relative.to_lowercase().starts_with(&needle.to_lowercase());
found.push((prefix, shown, is_dir));
}
found.sort_by(|a, b| {
b.0.cmp(&a.0)
.then(a.1.matches('/').count().cmp(&b.1.matches('/').count()))
.then(a.1.cmp(&b.1))
});
found
.into_iter()
.take(MAX_PATH_ITEMS)
.map(|(_, path, is_dir)| {
let path = if is_dir { format!("{path}/") } else { path };
CompletionEntry {
value: format!("@{path}"),
label: Some(path),
tag: is_dir.then(|| "dir".to_owned()),
..CompletionEntry::default()
}
})
.collect()
}
impl AgentSession {
#[must_use]
pub fn editor_text(&self) -> String {
lock(&self.inner.editor_text).clone()
}
pub async fn completions(&self, text: &str, cursor: usize) -> Vec<CompletionGroup> {
let before = &text[..byte_index(text, cursor)];
let cursor = before.chars().count();
let token_start = before
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map_or(0, |(i, c)| before[..i + c.len_utf8()].chars().count());
let token = &before[byte_index(before, token_start)..];
let mut groups = Vec::new();
if let Some(line) = before.strip_prefix('/') {
groups.extend(self.command_completions(line).await);
}
if let Some(query) = token.strip_prefix('@') {
let cwd = self.inner.cwd.clone();
let query = query.to_owned();
let items = tokio::task::spawn_blocking(move || path_candidates(&cwd, &query))
.await
.unwrap_or_default();
if !items.is_empty() {
groups.push(CompletionGroup {
source: "files".to_owned(),
from: Some(token_start),
items,
});
}
}
groups.extend(
self.contributed_completions(text, cursor, token_start)
.await,
);
groups
}
async fn command_completions(&self, line: &str) -> Option<CompletionGroup> {
let runner = self.runner();
let cx = runner.call_context().with_kind(CallKind::Command);
match line.split_once(' ') {
None => {
let items: Vec<CompletionEntry> = self
.commands()
.into_iter()
.filter(|c| fuzzy(&c.name, line))
.map(|c| CompletionEntry {
value: format!("/{}", c.name),
label: Some(format!("/{}", c.name)),
description: c.description,
tag: Some(c.source),
..CompletionEntry::default()
})
.collect();
(!items.is_empty()).then(|| CompletionGroup {
source: "commands".to_owned(),
from: Some(0),
items,
})
}
Some((name, argument)) => {
let command = runner.command(name).filter(|c| c.spec.completions)?;
let call =
command
.extension
.instance
.complete_command(&command.spec.name, argument, cx);
let items = tokio::time::timeout(COMPLETION_BUDGET, call)
.await
.unwrap_or_default();
(!items.is_empty()).then(|| CompletionGroup {
source: format!("command:{name}"),
from: Some(name.chars().count() + 2),
items: items
.into_iter()
.map(|item| CompletionEntry {
label: Some(item.label),
value: item.value,
description: item.description,
..CompletionEntry::default()
})
.collect(),
})
}
}
}
async fn contributed_completions(
&self,
text: &str,
cursor: usize,
token_start: usize,
) -> Vec<CompletionGroup> {
let runner = self.runner();
let cx = runner.call_context().with_kind(CallKind::Command);
let request = json!({"text": text, "cursor": cursor});
let contributors = runner.set().extensions().iter().map(|extension| {
let call = extension
.instance
.complete_input(request.clone(), cx.clone());
let id = extension.instance.id().to_owned();
async move {
let value = tokio::time::timeout(COMPLETION_BUDGET, call)
.await
.ok()
.flatten()?;
let groups: Vec<CompletionGroup> = serde_json::from_value(value).ok()?;
Some((id, groups))
}
});
futures::future::join_all(contributors)
.await
.into_iter()
.flatten()
.flat_map(|(id, contributed)| {
contributed
.into_iter()
.filter(|g| !g.items.is_empty())
.map(move |mut group| {
group.from.get_or_insert(token_start);
if group.source.is_empty() {
group.source.clone_from(&id);
}
group
})
})
.collect()
}
fn panel_owner(&self, entry_id: &str) -> Option<String> {
lock(&self.inner.registry)
.get(entry_id)
.map(|entry| entry.extension_id.clone())
.or_else(|| {
entry_id
.rsplit_once("/panel-")
.map(|(owner, _)| owner.to_owned())
})
}
pub fn ui_action(
&self,
panel_id: &str,
action_id: &str,
value: Option<JsonValue>,
) -> Result<(), FrontendError> {
self.dispatch_action(
&crate::ui::UiAction {
panel_id: panel_id.to_owned(),
action_id: action_id.to_owned(),
value,
..crate::ui::UiAction::default()
},
false,
)
}
pub fn ui_action_with(&self, action: &crate::ui::UiAction) -> Result<(), FrontendError> {
self.dispatch_action(action, action.action_id == "$close")
}
pub fn ui_visible(&self, frontend: &str, entry_id: &str, visible: bool) {
let change = lock(&self.inner.registry).set_visible(frontend, entry_id, visible);
if let Some(change) = change {
let action = crate::ui::UiAction {
panel_id: change.entry_id,
action_id: if change.visible { "$show" } else { "$hide" }.to_owned(),
..crate::ui::UiAction::default()
};
let _ = self.dispatch_action(&action, false);
}
}
#[must_use]
pub fn ui_snapshot(&self) -> crate::ui::UiSnapshot {
lock(&self.inner.registry).snapshot()
}
#[must_use]
pub fn ui_resync(&self) -> crate::ui::UiSnapshot {
self.ui_snapshot()
}
pub fn ui_fire(
&self,
extension: &str,
method: &str,
params: oino_ai::JsonObject,
) -> Result<crate::ui::ProduceOutcome, crate::ui::RegistryError> {
let outcome = {
let mut registry = lock(&self.inner.registry);
let outcome = registry.produce(extension, method, ¶ms);
if outcome.is_ok() {
registry.flush();
}
outcome
};
match outcome {
Ok(outcome) => {
let effect = matches!(outcome, crate::ui::ProduceOutcome::Effect)
&& !crate::ui::registry::is_meta_method(method);
if let Some(request) = self.inner.ui.fire(Some(extension), method, params)
&& effect
{
let mut registry = lock(&self.inner.registry);
registry.effect(request.to_registry());
registry.flush();
}
Ok(outcome)
}
Err(error) => {
self.inner.hub.publish(crate::events::ExtensionErrorEvent {
extension_path: extension.to_owned(),
event: method.to_owned(),
error: error.to_string(),
stack: None,
});
Err(error)
}
}
}
pub fn ui_register_turn_footer(&self, extension: &str, key: &str) {
let mut registry = lock(&self.inner.registry);
if let Err(error) = registry.register_turn_footer(extension, key) {
drop(registry);
self.inner.hub.publish(crate::events::ExtensionErrorEvent {
extension_path: extension.to_owned(),
event: "register-renderer".to_owned(),
error: error.to_string(),
stack: None,
});
} else {
registry.flush();
}
}
fn dispatch_action(
&self,
action: &crate::ui::UiAction,
close: bool,
) -> Result<(), FrontendError> {
let runner = self.runner();
let extension = self
.panel_owner(&action.panel_id)
.and_then(|owner| runner.set().get(&owner).cloned())
.ok_or_else(|| {
FrontendError::NotFound(format!("Unknown panel: {}", action.panel_id))
})?;
let mut payload = json!({
"type": "oino_ui_action",
"panelId": action.panel_id,
"actionId": action.action_id,
});
if let Some(value) = &action.value {
payload["value"] = value.clone();
}
if let Some(values) = &action.values {
payload["values"] = JsonValue::Object(values.clone());
}
if let Some(path) = &action.path {
payload["path"] = json!(path);
}
if let Some(source) = action.source {
let name = match source {
crate::ui::ActionSource::Tui => "tui",
crate::ui::ActionSource::App => "app",
crate::ui::ActionSource::Rpc => "rpc",
};
payload["source"] = json!(name);
}
let cx = runner.call_context().with_kind(CallKind::Command);
let session = self.clone();
let entry_id = action.panel_id.clone();
self.spawn_task(async move {
if let Err(error) = extension
.instance
.on_event("oino_ui_action", payload, cx)
.await
{
runner.emit_error(&extension.instance.path(), "oino_ui_action", error.message);
}
if close {
let mut registry = lock(&session.inner.registry);
registry.close(&entry_id);
registry.flush();
}
});
Ok(())
}
pub fn invoke_shortcut(&self, id: &str) -> Result<(), FrontendError> {
let runner = self.runner();
let extension = runner
.shortcuts()
.into_iter()
.find(|(_, spec)| spec.id == id)
.and_then(|(owner, _)| runner.set().get(&owner).cloned())
.ok_or_else(|| FrontendError::NotFound(format!("Unknown shortcut: {id}")))?;
let cx = runner.call_context().with_kind(CallKind::Shortcut);
let id = id.to_owned();
self.spawn_task(async move {
if let Err(error) = extension.instance.run_shortcut(&id, cx).await {
runner.emit_error(&extension.instance.path(), "shortcut", error.message);
}
});
Ok(())
}
pub(crate) fn publish_shortcuts(&self) {
let shortcuts: Vec<JsonValue> = self
.runner()
.shortcuts()
.into_iter()
.map(|(extension, spec)| {
json!({
"id": spec.id,
"keys": spec.default_keys,
"description": spec.description,
"extensionId": extension,
})
})
.collect();
let mut params = oino_ai::JsonObject::new();
params.insert("shortcuts".into(), JsonValue::Array(shortcuts));
let _ = self.inner.ui.fire(None, "setShortcuts", params);
}
pub async fn editor_event(&self, event: EditorEvent) -> bool {
lock(&self.inner.editor_text).clone_from(&event.text);
let runner = self.runner();
let mut payload = json!({
"type": "oino_editor",
"eventId": event.event_id,
"intent": event.intent,
"text": event.text,
});
if let Some(token) = &event.token_id {
payload["tokenId"] = json!(token);
}
if !runner.has_handlers("oino_editor") {
return false;
}
if !event.intent.consumable() {
self.spawn_task(async move {
runner.emit(payload).await;
});
return false;
}
runner.emit_consumable(payload).await
}
pub async fn service_call(
&self,
target: Option<&str>,
name: &str,
payload: JsonValue,
) -> Result<JsonValue, FrontendError> {
let runner = self.runner();
let extension = runner
.set()
.extensions()
.iter()
.filter(|e| target.is_none_or(|t| e.instance.id() == t))
.find(|e| e.instance.services().iter().any(|s| s == name))
.cloned()
.ok_or_else(|| {
FrontendError::NotFound(match target {
Some(target) => format!("Unknown service: {target}/{name}"),
None => format!("Unknown service: {name}"),
})
})?;
let cx = runner.call_context().with_kind(CallKind::Service);
let call = extension
.instance
.handle_service(name, payload, FRONTEND_CALLER, cx);
match tokio::time::timeout(SERVICE_TIMEOUT, call).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(error)) => Err(FrontendError::Failed(error.message)),
Err(_) => Err(FrontendError::Failed(format!(
"Service {name} timed out after {}s",
SERVICE_TIMEOUT.as_secs()
))),
}
}
#[must_use]
pub fn auth_providers(&self) -> Vec<AuthProviderInfo> {
let models = &self.inner.models;
let mut providers: Vec<AuthProviderInfo> = models
.registered_providers()
.into_iter()
.filter_map(|id| {
let registration = models.registration(&id)?;
let stored = models
.auth_storage()
.stored(&id)
.ok()
.flatten()
.map(|c| match c {
AuthCredential::ApiKey(_) => "api_key".to_owned(),
AuthCredential::Oauth(_) => "oauth".to_owned(),
});
Some(AuthProviderInfo {
name: registration.display_name().to_owned(),
oauth: registration.oauth,
configured: models.has_configured_auth(&id),
stored,
env: registration.auth_env.clone(),
id,
})
})
.collect();
providers.sort_by_key(|p| p.name.to_lowercase());
providers
}
pub async fn login(&self, provider: &str) -> Result<(), FrontendError> {
let models = &self.inner.models;
let registration = models
.registration(provider)
.ok_or_else(|| FrontendError::NotFound(format!("Unknown provider: {provider}")))?;
if !registration.oauth {
return Err(FrontendError::Unsupported(format!(
"{} does not support OAuth sign-in; set an API key instead",
registration.display_name()
)));
}
let runner = self.runner();
let extension = runner.provider_for_api(®istration.api).ok_or_else(|| {
FrontendError::NotFound(format!("No provider component for {}", registration.api))
})?;
let component = extension
.instance
.provider()
.ok_or_else(|| FrontendError::Unsupported(format!("{provider}: not a provider")))?;
let cx = runner.call_context().with_kind(CallKind::Command);
let mut value = component
.oauth_login(provider, cx)
.await
.map_err(|e| FrontendError::Failed(e.message))?;
if let Some(object) = value.as_object_mut() {
object.remove("type");
}
let credential: OAuthCredential = serde_json::from_value(value.clone())
.map_err(|e| FrontendError::Failed(format!("invalid OAuth credential: {e}")))?;
models
.auth_storage()
.set(provider, &AuthCredential::Oauth(credential))
.map_err(|e| FrontendError::Failed(e.to_string()))?;
value["type"] = json!("oauth");
crate::services::refresh_provider_models(runner.set(), models, provider, Some(value)).await;
self.refresh_current_model();
Ok(())
}
pub fn logout(&self, provider: &str) -> Result<(), FrontendError> {
let models = &self.inner.models;
if models.registration(provider).is_none() && !models.auth_storage().has_stored(provider) {
return Err(FrontendError::NotFound(format!(
"Unknown provider: {provider}"
)));
}
models
.auth_storage()
.remove(provider)
.map_err(|e| FrontendError::Failed(e.to_string()))
}
pub fn render_spec(&self, request: &RenderRequest) -> Result<Option<JsonValue>, FrontendError> {
let runner = self.runner();
let target = request.target.as_str();
let spec_target = request.target.spec_str();
let key = request.key.as_str();
let owner = runner
.renderers()
.into_iter()
.find(|(_, spec)| {
spec.get("target").and_then(JsonValue::as_str) == Some(spec_target)
&& spec.get("key").and_then(JsonValue::as_str) == Some(key)
})
.map(|(id, _)| id);
let Some(owner) = owner else {
return Ok(None);
};
let extension = runner
.set()
.get(&owner)
.cloned()
.ok_or_else(|| FrontendError::Failed(format!("unknown extension {owner}")))?;
let payload = if request.target == RenderTarget::TurnFooter {
json!({
"target": target,
"key": key,
"turnEntryId": request.turn_entry_id,
"turnIndex": request.turn_index,
"running": request.running,
})
} else {
json!({
"target": target,
"key": key,
"entryId": request.entry_id,
"density": request.density.as_str(),
"expanded": request.expanded,
"width": request.width.as_str(),
"data": request.data.clone(),
})
};
let cx = runner.call_context().with_kind(CallKind::Render);
Ok(extension
.instance
.render(payload, cx)
.filter(|v| !v.is_null()))
}
pub async fn set_api_key(&self, provider: &str, key: &str) -> Result<(), FrontendError> {
let key = key.trim();
if key.is_empty() {
return Err(FrontendError::Failed("API key cannot be empty".to_owned()));
}
let models = &self.inner.models;
let credential = AuthCredential::ApiKey(oino_models::auth::credential::ApiKeyCredential {
key: Some(key.to_owned()),
env: None,
extra: oino_ai::JsonObject::new(),
});
models
.auth_storage()
.set(provider, &credential)
.map_err(|e| FrontendError::Failed(e.to_string()))?;
crate::services::refresh_provider_models(
self.runner().set(),
models,
provider,
Some(json!({"type": "api_key", "key": key})),
)
.await;
self.refresh_current_model();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuzzy_and_indices() {
assert!(fuzzy("kanboard-add", "kbad"));
assert!(!fuzzy("model", "x"));
assert_eq!(byte_index("añb", 2), 3);
assert_eq!(byte_index("ab", 9), 2);
assert!(EditorIntent::EmptyArrowDown.consumable());
assert!(!EditorIntent::Changed.consumable());
}
#[test]
fn path_candidates_prefer_prefix_matches() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/lib.rs"), "").unwrap();
std::fs::write(dir.path().join("README.md"), "").unwrap();
let items = path_candidates(dir.path(), "src");
assert_eq!(items[0].value, "@src/");
assert_eq!(items[0].tag.as_deref(), Some("dir"));
assert!(items.iter().any(|i| i.value == "@src/lib.rs"));
let readme = path_candidates(dir.path(), "rdm");
assert_eq!(readme[0].value, "@README.md");
}
}