feat: 合并上游 Rust 实现,扩展 API/运行时/工具链能力
将 claw-code/rust/crates 的完整实现合并到主 workspace,涵盖
9 个 crate 的更新与 2 个新 crate 的引入。
API 层:
- 用原生 Anthropic 客户端(anthropic.rs)替换 claw_provider,
新增 prompt cache 减少重复请求开销
- 新增 HTTP 客户端构建器统一代理配置,OpenAI 兼容端增加
DashScope/Qwen 支持与抖动重试
- MessageRequest 扩展 temperature/top_p 等模型调参字段
- SSE 解析器增加 provider 上下文感知的错误信息
运行时(~11,000 行新增):
- 新增 bash 命令安全校验、分支锁碰撞检测、配置文件校验
- 新增会话存储与控制面、MCP 生命周期状态机与服务端实现
- 新增权限执行引擎、策略引擎、插件生命周期管理
- 新增 worker 启动编排、任务/定时任务注册表、信任解析器
- 保留 Windows cmd /C fallback
命令/插件/工具:
- commands 大幅重写,扩展 sandbox、doctor、plan 等 slash 命令
- plugins 新增 PostToolUseFailure hook 与宽容加载机制
- tools 新增 PDF 提取与 lane 补全工具
新增 crate:mock-anthropic-service(测试)、telemetry(遥测)
适配 claw-cli/server:ClawApiClient→AnthropicClient 重命名,
SlashCommand::parse 返回 Result,移除 session 级 Thinking 变体,
TokenUsage/ConversationMessage 补充序列化支持
This commit is contained in:
@@ -15,12 +15,20 @@ commands = { path = "../commands" }
|
||||
compat-harness = { path = "../compat-harness" }
|
||||
crossterm = "0.28"
|
||||
pulldown-cmark = "0.13"
|
||||
plugins = { path = "../plugins" }
|
||||
rustyline = "15"
|
||||
runtime = { path = "../runtime" }
|
||||
serde_json = "1"
|
||||
plugins = { path = "../plugins" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
syntect = "5"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
|
||||
tools = { path = "../tools" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
mock-anthropic-service = { path = "../mock-anthropic-service" }
|
||||
serde_json.workspace = true
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Get git SHA (short hash)
|
||||
let git_sha = Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| {
|
||||
if output.status.success() {
|
||||
String::from_utf8(output.stdout).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string());
|
||||
|
||||
println!("cargo:rustc-env=GIT_SHA={git_sha}");
|
||||
|
||||
// TARGET is always set by Cargo during build
|
||||
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
|
||||
println!("cargo:rustc-env=TARGET={target}");
|
||||
|
||||
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current date.
|
||||
let build_date = std::env::var("SOURCE_DATE_EPOCH")
|
||||
.ok()
|
||||
.and_then(|epoch| epoch.parse::<i64>().ok())
|
||||
.map_or_else(
|
||||
|| "unknown".to_string(),
|
||||
|_| std::env::var("BUILD_DATE").unwrap_or_else(|_| "unknown".to_string()),
|
||||
);
|
||||
println!("cargo:rustc-env=BUILD_DATE={build_date}");
|
||||
|
||||
// Rerun if git state changes
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::args::{OutputFormat, PermissionMode};
|
||||
use crate::input::{LineEditor, ReadOutcome};
|
||||
use crate::render::{Spinner, TerminalRenderer};
|
||||
use runtime::{ConversationClient, ConversationMessage, RuntimeError, StreamEvent, UsageSummary};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionConfig {
|
||||
pub model: String,
|
||||
pub permission_mode: PermissionMode,
|
||||
pub config: Option<PathBuf>,
|
||||
pub output_format: OutputFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionState {
|
||||
pub turns: usize,
|
||||
pub compacted_messages: usize,
|
||||
pub last_model: String,
|
||||
pub last_usage: UsageSummary,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
#[must_use]
|
||||
pub fn new(model: impl Into<String>) -> Self {
|
||||
Self {
|
||||
turns: 0,
|
||||
compacted_messages: 0,
|
||||
last_model: model.into(),
|
||||
last_usage: UsageSummary::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CommandResult {
|
||||
Continue,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SlashCommand {
|
||||
Help,
|
||||
Status,
|
||||
Compact,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
#[must_use]
|
||||
pub fn parse(input: &str) -> Option<Self> {
|
||||
let trimmed = input.trim();
|
||||
if !trimmed.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let command = trimmed
|
||||
.trim_start_matches('/')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
Some(match command {
|
||||
"help" => Self::Help,
|
||||
"status" => Self::Status,
|
||||
"compact" => Self::Compact,
|
||||
other => Self::Unknown(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct SlashCommandHandler {
|
||||
command: SlashCommand,
|
||||
summary: &'static str,
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_HANDLERS: &[SlashCommandHandler] = &[
|
||||
SlashCommandHandler {
|
||||
command: SlashCommand::Help,
|
||||
summary: "Show command help",
|
||||
},
|
||||
SlashCommandHandler {
|
||||
command: SlashCommand::Status,
|
||||
summary: "Show current session status",
|
||||
},
|
||||
SlashCommandHandler {
|
||||
command: SlashCommand::Compact,
|
||||
summary: "Compact local session history",
|
||||
},
|
||||
];
|
||||
|
||||
pub struct CliApp {
|
||||
config: SessionConfig,
|
||||
renderer: TerminalRenderer,
|
||||
state: SessionState,
|
||||
conversation_client: ConversationClient,
|
||||
conversation_history: Vec<ConversationMessage>,
|
||||
}
|
||||
|
||||
impl CliApp {
|
||||
pub fn new(config: SessionConfig) -> Result<Self, RuntimeError> {
|
||||
let state = SessionState::new(config.model.clone());
|
||||
let conversation_client = ConversationClient::from_env(config.model.clone())?;
|
||||
Ok(Self {
|
||||
config,
|
||||
renderer: TerminalRenderer::new(),
|
||||
state,
|
||||
conversation_client,
|
||||
conversation_history: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_repl(&mut self) -> io::Result<()> {
|
||||
let mut editor = LineEditor::new("› ", Vec::new());
|
||||
println!("Rusty Claude CLI interactive mode");
|
||||
println!("Type /help for commands. Shift+Enter or Ctrl+J inserts a newline.");
|
||||
|
||||
loop {
|
||||
match editor.read_line()? {
|
||||
ReadOutcome::Submit(input) => {
|
||||
if input.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
self.handle_submission(&input, &mut io::stdout())?;
|
||||
}
|
||||
ReadOutcome::Cancel => continue,
|
||||
ReadOutcome::Exit => break,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_prompt(&mut self, prompt: &str, out: &mut impl Write) -> io::Result<()> {
|
||||
self.render_response(prompt, out)
|
||||
}
|
||||
|
||||
pub fn handle_submission(
|
||||
&mut self,
|
||||
input: &str,
|
||||
out: &mut impl Write,
|
||||
) -> io::Result<CommandResult> {
|
||||
if let Some(command) = SlashCommand::parse(input) {
|
||||
return self.dispatch_slash_command(command, out);
|
||||
}
|
||||
|
||||
self.state.turns += 1;
|
||||
self.render_response(input, out)?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn dispatch_slash_command(
|
||||
&mut self,
|
||||
command: SlashCommand,
|
||||
out: &mut impl Write,
|
||||
) -> io::Result<CommandResult> {
|
||||
match command {
|
||||
SlashCommand::Help => Self::handle_help(out),
|
||||
SlashCommand::Status => self.handle_status(out),
|
||||
SlashCommand::Compact => self.handle_compact(out),
|
||||
SlashCommand::Unknown(name) => {
|
||||
writeln!(out, "Unknown slash command: /{name}")?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_help(out: &mut impl Write) -> io::Result<CommandResult> {
|
||||
writeln!(out, "Available commands:")?;
|
||||
for handler in SLASH_COMMAND_HANDLERS {
|
||||
let name = match handler.command {
|
||||
SlashCommand::Help => "/help",
|
||||
SlashCommand::Status => "/status",
|
||||
SlashCommand::Compact => "/compact",
|
||||
SlashCommand::Unknown(_) => continue,
|
||||
};
|
||||
writeln!(out, " {name:<9} {}", handler.summary)?;
|
||||
}
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn handle_status(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
||||
writeln!(
|
||||
out,
|
||||
"status: turns={} model={} permission-mode={:?} output-format={:?} last-usage={} in/{} out config={}",
|
||||
self.state.turns,
|
||||
self.state.last_model,
|
||||
self.config.permission_mode,
|
||||
self.config.output_format,
|
||||
self.state.last_usage.input_tokens,
|
||||
self.state.last_usage.output_tokens,
|
||||
self.config
|
||||
.config
|
||||
.as_ref()
|
||||
.map_or_else(|| String::from("<none>"), |path| path.display().to_string())
|
||||
)?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn handle_compact(&mut self, out: &mut impl Write) -> io::Result<CommandResult> {
|
||||
self.state.compacted_messages += self.state.turns;
|
||||
self.state.turns = 0;
|
||||
self.conversation_history.clear();
|
||||
writeln!(
|
||||
out,
|
||||
"Compacted session history into a local summary ({} messages total compacted).",
|
||||
self.state.compacted_messages
|
||||
)?;
|
||||
Ok(CommandResult::Continue)
|
||||
}
|
||||
|
||||
fn handle_stream_event(
|
||||
renderer: &TerminalRenderer,
|
||||
event: StreamEvent,
|
||||
stream_spinner: &mut Spinner,
|
||||
tool_spinner: &mut Spinner,
|
||||
saw_text: &mut bool,
|
||||
turn_usage: &mut UsageSummary,
|
||||
out: &mut impl Write,
|
||||
) {
|
||||
match event {
|
||||
StreamEvent::TextDelta(delta) => {
|
||||
if !*saw_text {
|
||||
let _ =
|
||||
stream_spinner.finish("Streaming response", renderer.color_theme(), out);
|
||||
*saw_text = true;
|
||||
}
|
||||
let _ = write!(out, "{delta}");
|
||||
let _ = out.flush();
|
||||
}
|
||||
StreamEvent::ToolCallStart { name, input } => {
|
||||
if *saw_text {
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
let _ = tool_spinner.tick(
|
||||
&format!("Running tool `{name}` with {input}"),
|
||||
renderer.color_theme(),
|
||||
out,
|
||||
);
|
||||
}
|
||||
StreamEvent::ToolCallResult {
|
||||
name,
|
||||
output,
|
||||
is_error,
|
||||
} => {
|
||||
let label = if is_error {
|
||||
format!("Tool `{name}` failed")
|
||||
} else {
|
||||
format!("Tool `{name}` completed")
|
||||
};
|
||||
let _ = tool_spinner.finish(&label, renderer.color_theme(), out);
|
||||
let rendered_output = format!("### Tool `{name}`\n\n```text\n{output}\n```\n");
|
||||
let _ = renderer.stream_markdown(&rendered_output, out);
|
||||
}
|
||||
StreamEvent::Usage(usage) => {
|
||||
*turn_usage = usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_turn_output(
|
||||
&self,
|
||||
summary: &runtime::TurnSummary,
|
||||
out: &mut impl Write,
|
||||
) -> io::Result<()> {
|
||||
match self.config.output_format {
|
||||
OutputFormat::Text => {
|
||||
writeln!(
|
||||
out,
|
||||
"\nToken usage: {} input / {} output",
|
||||
self.state.last_usage.input_tokens, self.state.last_usage.output_tokens
|
||||
)?;
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"message": summary.assistant_text,
|
||||
"usage": {
|
||||
"input_tokens": self.state.last_usage.input_tokens,
|
||||
"output_tokens": self.state.last_usage.output_tokens,
|
||||
}
|
||||
})
|
||||
)?;
|
||||
}
|
||||
OutputFormat::Ndjson => {
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"text": summary.assistant_text,
|
||||
"usage": {
|
||||
"input_tokens": self.state.last_usage.input_tokens,
|
||||
"output_tokens": self.state.last_usage.output_tokens,
|
||||
}
|
||||
})
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_response(&mut self, input: &str, out: &mut impl Write) -> io::Result<()> {
|
||||
let mut stream_spinner = Spinner::new();
|
||||
stream_spinner.tick(
|
||||
"Opening conversation stream",
|
||||
self.renderer.color_theme(),
|
||||
out,
|
||||
)?;
|
||||
|
||||
let mut turn_usage = UsageSummary::default();
|
||||
let mut tool_spinner = Spinner::new();
|
||||
let mut saw_text = false;
|
||||
let renderer = &self.renderer;
|
||||
|
||||
let result =
|
||||
self.conversation_client
|
||||
.run_turn(&mut self.conversation_history, input, |event| {
|
||||
Self::handle_stream_event(
|
||||
renderer,
|
||||
event,
|
||||
&mut stream_spinner,
|
||||
&mut tool_spinner,
|
||||
&mut saw_text,
|
||||
&mut turn_usage,
|
||||
out,
|
||||
);
|
||||
});
|
||||
|
||||
let summary = match result {
|
||||
Ok(summary) => summary,
|
||||
Err(error) => {
|
||||
stream_spinner.fail(
|
||||
"Streaming response failed",
|
||||
self.renderer.color_theme(),
|
||||
out,
|
||||
)?;
|
||||
return Err(io::Error::other(error));
|
||||
}
|
||||
};
|
||||
self.state.last_usage = summary.usage.clone();
|
||||
if saw_text {
|
||||
writeln!(out)?;
|
||||
} else {
|
||||
stream_spinner.finish("Streaming response", self.renderer.color_theme(), out)?;
|
||||
}
|
||||
|
||||
self.write_turn_output(&summary, out)?;
|
||||
let _ = turn_usage;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::args::{OutputFormat, PermissionMode};
|
||||
|
||||
use super::{CommandResult, SessionConfig, SlashCommand};
|
||||
|
||||
#[test]
|
||||
fn parses_required_slash_commands() {
|
||||
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
||||
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/compact now"),
|
||||
Some(SlashCommand::Compact)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_lists_commands() {
|
||||
let mut out = Vec::new();
|
||||
let result = super::CliApp::handle_help(&mut out).expect("help succeeds");
|
||||
assert_eq!(result, CommandResult::Continue);
|
||||
let output = String::from_utf8_lossy(&out);
|
||||
assert!(output.contains("/help"));
|
||||
assert!(output.contains("/status"));
|
||||
assert!(output.contains("/compact"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_state_tracks_config_values() {
|
||||
let config = SessionConfig {
|
||||
model: "claude".into(),
|
||||
permission_mode: PermissionMode::WorkspaceWrite,
|
||||
config: Some(PathBuf::from("settings.toml")),
|
||||
output_format: OutputFormat::Text,
|
||||
};
|
||||
|
||||
assert_eq!(config.model, "claude");
|
||||
assert_eq!(config.permission_mode, PermissionMode::WorkspaceWrite);
|
||||
assert_eq!(config.config, Some(PathBuf::from("settings.toml")));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
|
||||
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
|
||||
#[command(
|
||||
name = "rusty-claude-cli",
|
||||
version,
|
||||
about = "Rust Claude CLI prototype"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[arg(long, default_value = "claude-opus-4-6")]
|
||||
pub model: String,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = PermissionMode::WorkspaceWrite)]
|
||||
pub permission_mode: PermissionMode,
|
||||
|
||||
#[arg(long)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
|
||||
pub output_format: OutputFormat,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
/// Read upstream TS sources and print extracted counts
|
||||
DumpManifests,
|
||||
/// Print the current bootstrap phase skeleton
|
||||
BootstrapPlan,
|
||||
/// Start the OAuth login flow
|
||||
Login,
|
||||
/// Clear saved OAuth credentials
|
||||
Logout,
|
||||
/// Run a non-interactive prompt and exit
|
||||
Prompt { prompt: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
||||
pub enum PermissionMode {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
|
||||
pub enum OutputFormat {
|
||||
Text,
|
||||
Json,
|
||||
Ndjson,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::{Cli, Command, OutputFormat, PermissionMode};
|
||||
|
||||
#[test]
|
||||
fn parses_requested_flags() {
|
||||
let cli = Cli::parse_from([
|
||||
"rusty-claude-cli",
|
||||
"--model",
|
||||
"claude-3-5-haiku",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--config",
|
||||
"/tmp/config.toml",
|
||||
"--output-format",
|
||||
"ndjson",
|
||||
"prompt",
|
||||
"hello",
|
||||
"world",
|
||||
]);
|
||||
|
||||
assert_eq!(cli.model, "claude-3-5-haiku");
|
||||
assert_eq!(cli.permission_mode, PermissionMode::ReadOnly);
|
||||
assert_eq!(
|
||||
cli.config.as_deref(),
|
||||
Some(std::path::Path::new("/tmp/config.toml"))
|
||||
);
|
||||
assert_eq!(cli.output_format, OutputFormat::Ndjson);
|
||||
assert_eq!(
|
||||
cli.command,
|
||||
Some(Command::Prompt {
|
||||
prompt: vec!["hello".into(), "world".into()]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_login_and_logout_commands() {
|
||||
let login = Cli::parse_from(["rusty-claude-cli", "login"]);
|
||||
assert_eq!(login.command, Some(Command::Login));
|
||||
|
||||
let logout = Cli::parse_from(["rusty-claude-cli", "logout"]);
|
||||
assert_eq!(logout.command, Some(Command::Logout));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const STARTER_CLAUDE_JSON: &str = concat!(
|
||||
const STARTER_CLAW_JSON: &str = concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" \"defaultMode\": \"dontAsk\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
);
|
||||
const GITIGNORE_COMMENT: &str = "# Claude Code local artifacts";
|
||||
const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
|
||||
const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
|
||||
const GITIGNORE_ENTRIES: [&str; 2] = [".claw/settings.local.json", ".claw/sessions/"];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum InitStatus {
|
||||
@@ -80,16 +80,16 @@ struct RepoDetection {
|
||||
pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
|
||||
let mut artifacts = Vec::new();
|
||||
|
||||
let claude_dir = cwd.join(".claude");
|
||||
let claw_dir = cwd.join(".claw");
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claude/",
|
||||
status: ensure_dir(&claude_dir)?,
|
||||
name: ".claw/",
|
||||
status: ensure_dir(&claw_dir)?,
|
||||
});
|
||||
|
||||
let claude_json = cwd.join(".claude.json");
|
||||
let claw_json = cwd.join(".claw.json");
|
||||
artifacts.push(InitArtifact {
|
||||
name: ".claude.json",
|
||||
status: write_file_if_missing(&claude_json, STARTER_CLAUDE_JSON)?,
|
||||
name: ".claw.json",
|
||||
status: write_file_if_missing(&claw_json, STARTER_CLAW_JSON)?,
|
||||
});
|
||||
|
||||
let gitignore = cwd.join(".gitignore");
|
||||
@@ -164,7 +164,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
|
||||
let mut lines = vec![
|
||||
"# CLAUDE.md".to_string(),
|
||||
String::new(),
|
||||
"This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.".to_string(),
|
||||
"This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
@@ -209,7 +209,7 @@ pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
|
||||
|
||||
lines.push("## Working agreement".to_string());
|
||||
lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
|
||||
lines.push("- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.".to_string());
|
||||
lines.push("- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.".to_string());
|
||||
lines.push("- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
@@ -354,26 +354,27 @@ mod tests {
|
||||
|
||||
let report = initialize_repo(&root).expect("init should succeed");
|
||||
let rendered = report.render();
|
||||
assert!(rendered.contains(".claude/ created"));
|
||||
assert!(rendered.contains(".claude.json created"));
|
||||
assert!(rendered.contains(".claw/"));
|
||||
assert!(rendered.contains(".claw.json"));
|
||||
assert!(rendered.contains("created"));
|
||||
assert!(rendered.contains(".gitignore created"));
|
||||
assert!(rendered.contains("CLAUDE.md created"));
|
||||
assert!(root.join(".claude").is_dir());
|
||||
assert!(root.join(".claude.json").is_file());
|
||||
assert!(root.join(".claw").is_dir());
|
||||
assert!(root.join(".claw.json").is_file());
|
||||
assert!(root.join("CLAUDE.md").is_file());
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join(".claude.json")).expect("read claude json"),
|
||||
fs::read_to_string(root.join(".claw.json")).expect("read claw json"),
|
||||
concat!(
|
||||
"{\n",
|
||||
" \"permissions\": {\n",
|
||||
" \"defaultMode\": \"acceptEdits\"\n",
|
||||
" \"defaultMode\": \"dontAsk\"\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
)
|
||||
);
|
||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||
assert!(gitignore.contains(".claude/settings.local.json"));
|
||||
assert!(gitignore.contains(".claude/sessions/"));
|
||||
assert!(gitignore.contains(".claw/settings.local.json"));
|
||||
assert!(gitignore.contains(".claw/sessions/"));
|
||||
let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
|
||||
assert!(claude_md.contains("Languages: Rust."));
|
||||
assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
|
||||
@@ -386,8 +387,7 @@ mod tests {
|
||||
let root = temp_dir();
|
||||
fs::create_dir_all(&root).expect("create root");
|
||||
fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
|
||||
fs::write(root.join(".gitignore"), ".claude/settings.local.json\n")
|
||||
.expect("write gitignore");
|
||||
fs::write(root.join(".gitignore"), ".claw/settings.local.json\n").expect("write gitignore");
|
||||
|
||||
let first = initialize_repo(&root).expect("first init should succeed");
|
||||
assert!(first
|
||||
@@ -395,8 +395,9 @@ mod tests {
|
||||
.contains("CLAUDE.md skipped (already exists)"));
|
||||
let second = initialize_repo(&root).expect("second init should succeed");
|
||||
let second_rendered = second.render();
|
||||
assert!(second_rendered.contains(".claude/ skipped (already exists)"));
|
||||
assert!(second_rendered.contains(".claude.json skipped (already exists)"));
|
||||
assert!(second_rendered.contains(".claw/"));
|
||||
assert!(second_rendered.contains(".claw.json"));
|
||||
assert!(second_rendered.contains("skipped (already exists)"));
|
||||
assert!(second_rendered.contains(".gitignore skipped (already exists)"));
|
||||
assert!(second_rendered.contains("CLAUDE.md skipped (already exists)"));
|
||||
assert_eq!(
|
||||
@@ -404,8 +405,8 @@ mod tests {
|
||||
"custom guidance\n"
|
||||
);
|
||||
let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
|
||||
assert_eq!(gitignore.matches(".claude/settings.local.json").count(), 1);
|
||||
assert_eq!(gitignore.matches(".claude/sessions/").count(), 1);
|
||||
assert_eq!(gitignore.matches(".claw/settings.local.json").count(), 1);
|
||||
assert_eq!(gitignore.matches(".claw/sessions/").count(), 1);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
@@ -1,166 +1,17 @@
|
||||
use std::borrow::Cow;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeSet;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use crossterm::cursor::{MoveDown, MoveToColumn, MoveUp};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::queue;
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InputBuffer {
|
||||
buffer: String,
|
||||
cursor: usize,
|
||||
}
|
||||
|
||||
impl InputBuffer {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, ch: char) {
|
||||
self.buffer.insert(self.cursor, ch);
|
||||
self.cursor += ch.len_utf8();
|
||||
}
|
||||
|
||||
pub fn insert_newline(&mut self) {
|
||||
self.insert('\n');
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
if self.cursor == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let previous = self.buffer[..self.cursor]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map_or(0, |(idx, _)| idx);
|
||||
self.buffer.drain(previous..self.cursor);
|
||||
self.cursor = previous;
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) {
|
||||
if self.cursor == 0 {
|
||||
return;
|
||||
}
|
||||
self.cursor = self.buffer[..self.cursor]
|
||||
.char_indices()
|
||||
.last()
|
||||
.map_or(0, |(idx, _)| idx);
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) {
|
||||
if self.cursor >= self.buffer.len() {
|
||||
return;
|
||||
}
|
||||
if let Some(next) = self.buffer[self.cursor..].chars().next() {
|
||||
self.cursor += next.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_home(&mut self) {
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
pub fn move_end(&mut self) {
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.buffer
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn cursor(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
|
||||
pub fn replace(&mut self, value: impl Into<String>) {
|
||||
self.buffer = value.into();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn current_command_prefix(&self) -> Option<&str> {
|
||||
if self.cursor != self.buffer.len() {
|
||||
return None;
|
||||
}
|
||||
let prefix = &self.buffer[..self.cursor];
|
||||
if prefix.contains(char::is_whitespace) || !prefix.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
Some(prefix)
|
||||
}
|
||||
|
||||
pub fn complete_slash_command(&mut self, candidates: &[String]) -> bool {
|
||||
let Some(prefix) = self.current_command_prefix() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let matches = candidates
|
||||
.iter()
|
||||
.filter(|candidate| candidate.starts_with(prefix))
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
if matches.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let replacement = longest_common_prefix(&matches);
|
||||
if replacement == prefix {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.replace(replacement);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderedBuffer {
|
||||
lines: Vec<String>,
|
||||
cursor_row: u16,
|
||||
cursor_col: u16,
|
||||
}
|
||||
|
||||
impl RenderedBuffer {
|
||||
#[must_use]
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
fn write(&self, out: &mut impl Write) -> io::Result<()> {
|
||||
for (index, line) in self.lines.iter().enumerate() {
|
||||
if index > 0 {
|
||||
writeln!(out)?;
|
||||
}
|
||||
write!(out, "{line}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn lines(&self) -> &[String] {
|
||||
&self.lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
pub fn cursor_position(&self) -> (u16, u16) {
|
||||
(self.cursor_row, self.cursor_col)
|
||||
}
|
||||
}
|
||||
use rustyline::completion::{Completer, Pair};
|
||||
use rustyline::error::ReadlineError;
|
||||
use rustyline::highlight::{CmdKind, Highlighter};
|
||||
use rustyline::hint::Hinter;
|
||||
use rustyline::history::DefaultHistory;
|
||||
use rustyline::validate::Validator;
|
||||
use rustyline::{
|
||||
Cmd, CompletionType, Config, Context, EditMode, Editor, Helper, KeyCode, KeyEvent, Modifiers,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReadOutcome {
|
||||
@@ -169,25 +20,105 @@ pub enum ReadOutcome {
|
||||
Exit,
|
||||
}
|
||||
|
||||
struct SlashCommandHelper {
|
||||
completions: Vec<String>,
|
||||
current_line: RefCell<String>,
|
||||
}
|
||||
|
||||
impl SlashCommandHelper {
|
||||
fn new(completions: Vec<String>) -> Self {
|
||||
Self {
|
||||
completions: normalize_completions(completions),
|
||||
current_line: RefCell::new(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_current_line(&self) {
|
||||
self.current_line.borrow_mut().clear();
|
||||
}
|
||||
|
||||
fn current_line(&self) -> String {
|
||||
self.current_line.borrow().clone()
|
||||
}
|
||||
|
||||
fn set_current_line(&self, line: &str) {
|
||||
let mut current = self.current_line.borrow_mut();
|
||||
current.clear();
|
||||
current.push_str(line);
|
||||
}
|
||||
|
||||
fn set_completions(&mut self, completions: Vec<String>) {
|
||||
self.completions = normalize_completions(completions);
|
||||
}
|
||||
}
|
||||
|
||||
impl Completer for SlashCommandHelper {
|
||||
type Candidate = Pair;
|
||||
|
||||
fn complete(
|
||||
&self,
|
||||
line: &str,
|
||||
pos: usize,
|
||||
_ctx: &Context<'_>,
|
||||
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
|
||||
let Some(prefix) = slash_command_prefix(line, pos) else {
|
||||
return Ok((0, Vec::new()));
|
||||
};
|
||||
|
||||
let matches = self
|
||||
.completions
|
||||
.iter()
|
||||
.filter(|candidate| candidate.starts_with(prefix))
|
||||
.map(|candidate| Pair {
|
||||
display: candidate.clone(),
|
||||
replacement: candidate.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok((0, matches))
|
||||
}
|
||||
}
|
||||
|
||||
impl Hinter for SlashCommandHelper {
|
||||
type Hint = String;
|
||||
}
|
||||
|
||||
impl Highlighter for SlashCommandHelper {
|
||||
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
||||
self.set_current_line(line);
|
||||
Cow::Borrowed(line)
|
||||
}
|
||||
|
||||
fn highlight_char(&self, line: &str, _pos: usize, _kind: CmdKind) -> bool {
|
||||
self.set_current_line(line);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Validator for SlashCommandHelper {}
|
||||
impl Helper for SlashCommandHelper {}
|
||||
|
||||
pub struct LineEditor {
|
||||
prompt: String,
|
||||
continuation_prompt: String,
|
||||
history: Vec<String>,
|
||||
history_index: Option<usize>,
|
||||
draft: Option<String>,
|
||||
completions: Vec<String>,
|
||||
editor: Editor<SlashCommandHelper, DefaultHistory>,
|
||||
}
|
||||
|
||||
impl LineEditor {
|
||||
#[must_use]
|
||||
pub fn new(prompt: impl Into<String>, completions: Vec<String>) -> Self {
|
||||
let config = Config::builder()
|
||||
.completion_type(CompletionType::List)
|
||||
.edit_mode(EditMode::Emacs)
|
||||
.build();
|
||||
let mut editor = Editor::<SlashCommandHelper, DefaultHistory>::with_config(config)
|
||||
.expect("rustyline editor should initialize");
|
||||
editor.set_helper(Some(SlashCommandHelper::new(completions)));
|
||||
editor.bind_sequence(KeyEvent(KeyCode::Char('J'), Modifiers::CTRL), Cmd::Newline);
|
||||
editor.bind_sequence(KeyEvent(KeyCode::Enter, Modifiers::SHIFT), Cmd::Newline);
|
||||
|
||||
Self {
|
||||
prompt: prompt.into(),
|
||||
continuation_prompt: String::from("> "),
|
||||
history: Vec::new(),
|
||||
history_index: None,
|
||||
draft: None,
|
||||
completions,
|
||||
editor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,9 +127,14 @@ impl LineEditor {
|
||||
if entry.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
self.history.push(entry);
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
|
||||
let _ = self.editor.add_history_entry(entry);
|
||||
}
|
||||
|
||||
pub fn set_completions(&mut self, completions: Vec<String>) {
|
||||
if let Some(helper) = self.editor.helper_mut() {
|
||||
helper.set_completions(completions);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_line(&mut self) -> io::Result<ReadOutcome> {
|
||||
@@ -206,45 +142,43 @@ impl LineEditor {
|
||||
return self.read_line_fallback();
|
||||
}
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
let mut input = InputBuffer::new();
|
||||
let mut rendered_lines = 1usize;
|
||||
self.redraw(&mut stdout, &input, rendered_lines)?;
|
||||
if let Some(helper) = self.editor.helper_mut() {
|
||||
helper.reset_current_line();
|
||||
}
|
||||
|
||||
loop {
|
||||
let event = event::read()?;
|
||||
if let Event::Key(key) = event {
|
||||
match self.handle_key(key, &mut input) {
|
||||
EditorAction::Continue => {
|
||||
rendered_lines = self.redraw(&mut stdout, &input, rendered_lines)?;
|
||||
}
|
||||
EditorAction::Submit => {
|
||||
disable_raw_mode()?;
|
||||
writeln!(stdout)?;
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
return Ok(ReadOutcome::Submit(input.as_str().to_owned()));
|
||||
}
|
||||
EditorAction::Cancel => {
|
||||
disable_raw_mode()?;
|
||||
writeln!(stdout)?;
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
return Ok(ReadOutcome::Cancel);
|
||||
}
|
||||
EditorAction::Exit => {
|
||||
disable_raw_mode()?;
|
||||
writeln!(stdout)?;
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
return Ok(ReadOutcome::Exit);
|
||||
}
|
||||
match self.editor.readline(&self.prompt) {
|
||||
Ok(line) => Ok(ReadOutcome::Submit(line)),
|
||||
Err(ReadlineError::Interrupted) => {
|
||||
let has_input = !self.current_line().is_empty();
|
||||
self.finish_interrupted_read()?;
|
||||
if has_input {
|
||||
Ok(ReadOutcome::Cancel)
|
||||
} else {
|
||||
Ok(ReadOutcome::Exit)
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Eof) => {
|
||||
self.finish_interrupted_read()?;
|
||||
Ok(ReadOutcome::Exit)
|
||||
}
|
||||
Err(error) => Err(io::Error::other(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_line(&self) -> String {
|
||||
self.editor
|
||||
.helper()
|
||||
.map_or_else(String::new, SlashCommandHelper::current_line)
|
||||
}
|
||||
|
||||
fn finish_interrupted_read(&mut self) -> io::Result<()> {
|
||||
if let Some(helper) = self.editor.helper_mut() {
|
||||
helper.reset_current_line();
|
||||
}
|
||||
let mut stdout = io::stdout();
|
||||
writeln!(stdout)
|
||||
}
|
||||
|
||||
fn read_line_fallback(&self) -> io::Result<ReadOutcome> {
|
||||
let mut stdout = io::stdout();
|
||||
write!(stdout, "{}", self.prompt)?;
|
||||
@@ -261,388 +195,136 @@ impl LineEditor {
|
||||
}
|
||||
Ok(ReadOutcome::Submit(buffer))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn handle_key(&mut self, key: KeyEvent, input: &mut InputBuffer) -> EditorAction {
|
||||
match key {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if input.as_str().is_empty() {
|
||||
EditorAction::Exit
|
||||
} else {
|
||||
input.clear();
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
EditorAction::Cancel
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
input.insert_newline();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::SHIFT) => {
|
||||
input.insert_newline();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => EditorAction::Submit,
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} => {
|
||||
input.backspace();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
..
|
||||
} => {
|
||||
input.move_left();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
..
|
||||
} => {
|
||||
input.move_right();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => {
|
||||
self.navigate_history_up(input);
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
} => {
|
||||
self.navigate_history_down(input);
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Tab, ..
|
||||
} => {
|
||||
input.complete_slash_command(&self.completions);
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Home,
|
||||
..
|
||||
} => {
|
||||
input.move_home();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::End, ..
|
||||
} => {
|
||||
input.move_end();
|
||||
EditorAction::Continue
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
input.clear();
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
EditorAction::Cancel
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(ch),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT => {
|
||||
input.insert(ch);
|
||||
self.history_index = None;
|
||||
self.draft = None;
|
||||
EditorAction::Continue
|
||||
}
|
||||
_ => EditorAction::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_history_up(&mut self, input: &mut InputBuffer) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.history_index {
|
||||
Some(0) => {}
|
||||
Some(index) => {
|
||||
let next_index = index - 1;
|
||||
input.replace(self.history[next_index].clone());
|
||||
self.history_index = Some(next_index);
|
||||
}
|
||||
None => {
|
||||
self.draft = Some(input.as_str().to_owned());
|
||||
let next_index = self.history.len() - 1;
|
||||
input.replace(self.history[next_index].clone());
|
||||
self.history_index = Some(next_index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn navigate_history_down(&mut self, input: &mut InputBuffer) {
|
||||
let Some(index) = self.history_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
if index + 1 < self.history.len() {
|
||||
let next_index = index + 1;
|
||||
input.replace(self.history[next_index].clone());
|
||||
self.history_index = Some(next_index);
|
||||
return;
|
||||
}
|
||||
|
||||
input.replace(self.draft.take().unwrap_or_default());
|
||||
self.history_index = None;
|
||||
}
|
||||
|
||||
fn redraw(
|
||||
&self,
|
||||
out: &mut impl Write,
|
||||
input: &InputBuffer,
|
||||
previous_line_count: usize,
|
||||
) -> io::Result<usize> {
|
||||
let rendered = render_buffer(&self.prompt, &self.continuation_prompt, input);
|
||||
if previous_line_count > 1 {
|
||||
queue!(out, MoveUp(saturating_u16(previous_line_count - 1)))?;
|
||||
}
|
||||
queue!(out, MoveToColumn(0), Clear(ClearType::FromCursorDown),)?;
|
||||
rendered.write(out)?;
|
||||
queue!(
|
||||
out,
|
||||
MoveUp(saturating_u16(rendered.line_count().saturating_sub(1))),
|
||||
MoveToColumn(0),
|
||||
)?;
|
||||
if rendered.cursor_row > 0 {
|
||||
queue!(out, MoveDown(rendered.cursor_row))?;
|
||||
}
|
||||
queue!(out, MoveToColumn(rendered.cursor_col))?;
|
||||
out.flush()?;
|
||||
Ok(rendered.line_count())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EditorAction {
|
||||
Continue,
|
||||
Submit,
|
||||
Cancel,
|
||||
Exit,
|
||||
fn slash_command_prefix(line: &str, pos: usize) -> Option<&str> {
|
||||
if pos != line.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let prefix = &line[..pos];
|
||||
if !prefix.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(prefix)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render_buffer(
|
||||
prompt: &str,
|
||||
continuation_prompt: &str,
|
||||
input: &InputBuffer,
|
||||
) -> RenderedBuffer {
|
||||
let before_cursor = &input.as_str()[..input.cursor];
|
||||
let cursor_row = saturating_u16(before_cursor.chars().filter(|ch| *ch == '\n').count());
|
||||
let cursor_line = before_cursor.rsplit('\n').next().unwrap_or_default();
|
||||
let cursor_prompt = if cursor_row == 0 {
|
||||
prompt
|
||||
} else {
|
||||
continuation_prompt
|
||||
};
|
||||
let cursor_col = saturating_u16(cursor_prompt.chars().count() + cursor_line.chars().count());
|
||||
|
||||
let mut lines = Vec::new();
|
||||
for (index, line) in input.as_str().split('\n').enumerate() {
|
||||
let prefix = if index == 0 {
|
||||
prompt
|
||||
} else {
|
||||
continuation_prompt
|
||||
};
|
||||
lines.push(format!("{prefix}{line}"));
|
||||
}
|
||||
if lines.is_empty() {
|
||||
lines.push(prompt.to_string());
|
||||
}
|
||||
|
||||
RenderedBuffer {
|
||||
lines,
|
||||
cursor_row,
|
||||
cursor_col,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn longest_common_prefix(values: &[&str]) -> String {
|
||||
let Some(first) = values.first() else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut prefix = (*first).to_string();
|
||||
for value in values.iter().skip(1) {
|
||||
while !value.starts_with(&prefix) {
|
||||
prefix.pop();
|
||||
if prefix.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
fn saturating_u16(value: usize) -> u16 {
|
||||
u16::try_from(value).unwrap_or(u16::MAX)
|
||||
fn normalize_completions(completions: Vec<String>) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
completions
|
||||
.into_iter()
|
||||
.filter(|candidate| candidate.starts_with('/'))
|
||||
.filter(|candidate| seen.insert(candidate.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{render_buffer, InputBuffer, LineEditor};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use super::{slash_command_prefix, LineEditor, SlashCommandHelper};
|
||||
use rustyline::completion::Completer;
|
||||
use rustyline::highlight::Highlighter;
|
||||
use rustyline::history::{DefaultHistory, History};
|
||||
use rustyline::Context;
|
||||
|
||||
fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
#[test]
|
||||
fn extracts_terminal_slash_command_prefixes_with_arguments() {
|
||||
assert_eq!(slash_command_prefix("/he", 3), Some("/he"));
|
||||
assert_eq!(slash_command_prefix("/help me", 8), Some("/help me"));
|
||||
assert_eq!(
|
||||
slash_command_prefix("/session switch ses", 19),
|
||||
Some("/session switch ses")
|
||||
);
|
||||
assert_eq!(slash_command_prefix("hello", 5), None);
|
||||
assert_eq!(slash_command_prefix("/help", 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_basic_line_editing() {
|
||||
let mut input = InputBuffer::new();
|
||||
input.insert('h');
|
||||
input.insert('i');
|
||||
input.move_end();
|
||||
input.insert_newline();
|
||||
input.insert('x');
|
||||
|
||||
assert_eq!(input.as_str(), "hi\nx");
|
||||
assert_eq!(input.cursor(), 4);
|
||||
|
||||
input.move_left();
|
||||
input.backspace();
|
||||
assert_eq!(input.as_str(), "hix");
|
||||
assert_eq!(input.cursor(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_unique_slash_command() {
|
||||
let mut input = InputBuffer::new();
|
||||
for ch in "/he".chars() {
|
||||
input.insert(ch);
|
||||
}
|
||||
|
||||
assert!(input.complete_slash_command(&[
|
||||
fn completes_matching_slash_commands() {
|
||||
let helper = SlashCommandHelper::new(vec![
|
||||
"/help".to_string(),
|
||||
"/hello".to_string(),
|
||||
"/status".to_string(),
|
||||
]));
|
||||
assert_eq!(input.as_str(), "/hel");
|
||||
]);
|
||||
let history = DefaultHistory::new();
|
||||
let ctx = Context::new(&history);
|
||||
let (start, matches) = helper
|
||||
.complete("/he", 3, &ctx)
|
||||
.expect("completion should work");
|
||||
|
||||
assert!(input.complete_slash_command(&["/help".to_string(), "/status".to_string()]));
|
||||
assert_eq!(input.as_str(), "/help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_completion_when_prefix_is_not_a_slash_command() {
|
||||
let mut input = InputBuffer::new();
|
||||
for ch in "hello".chars() {
|
||||
input.insert(ch);
|
||||
}
|
||||
|
||||
assert!(!input.complete_slash_command(&["/help".to_string()]));
|
||||
assert_eq!(input.as_str(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_navigation_restores_current_draft() {
|
||||
let mut editor = LineEditor::new("› ", vec![]);
|
||||
editor.push_history("/help");
|
||||
editor.push_history("status report");
|
||||
|
||||
let mut input = InputBuffer::new();
|
||||
for ch in "draft".chars() {
|
||||
input.insert(ch);
|
||||
}
|
||||
|
||||
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
|
||||
assert_eq!(input.as_str(), "status report");
|
||||
|
||||
let _ = editor.handle_key(key(KeyCode::Up), &mut input);
|
||||
assert_eq!(input.as_str(), "/help");
|
||||
|
||||
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
|
||||
assert_eq!(input.as_str(), "status report");
|
||||
|
||||
let _ = editor.handle_key(key(KeyCode::Down), &mut input);
|
||||
assert_eq!(input.as_str(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_key_completes_from_editor_candidates() {
|
||||
let mut editor = LineEditor::new(
|
||||
"› ",
|
||||
vec![
|
||||
"/help".to_string(),
|
||||
"/status".to_string(),
|
||||
"/session".to_string(),
|
||||
],
|
||||
);
|
||||
let mut input = InputBuffer::new();
|
||||
for ch in "/st".chars() {
|
||||
input.insert(ch);
|
||||
}
|
||||
|
||||
let _ = editor.handle_key(key(KeyCode::Tab), &mut input);
|
||||
assert_eq!(input.as_str(), "/status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_multiline_buffers_with_continuation_prompt() {
|
||||
let mut input = InputBuffer::new();
|
||||
for ch in "hello\nworld".chars() {
|
||||
if ch == '\n' {
|
||||
input.insert_newline();
|
||||
} else {
|
||||
input.insert(ch);
|
||||
}
|
||||
}
|
||||
|
||||
let rendered = render_buffer("› ", "> ", &input);
|
||||
assert_eq!(start, 0);
|
||||
assert_eq!(
|
||||
rendered.lines(),
|
||||
&["› hello".to_string(), "> world".to_string()]
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|candidate| candidate.replacement)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["/help".to_string(), "/hello".to_string()]
|
||||
);
|
||||
assert_eq!(rendered.cursor_position(), (1, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_exits_only_when_buffer_is_empty() {
|
||||
let mut editor = LineEditor::new("› ", vec![]);
|
||||
let mut empty = InputBuffer::new();
|
||||
assert!(matches!(
|
||||
editor.handle_key(
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
&mut empty,
|
||||
),
|
||||
super::EditorAction::Exit
|
||||
));
|
||||
fn completes_matching_slash_command_arguments() {
|
||||
let helper = SlashCommandHelper::new(vec![
|
||||
"/model".to_string(),
|
||||
"/model opus".to_string(),
|
||||
"/model sonnet".to_string(),
|
||||
"/session switch alpha".to_string(),
|
||||
]);
|
||||
let history = DefaultHistory::new();
|
||||
let ctx = Context::new(&history);
|
||||
let (start, matches) = helper
|
||||
.complete("/model o", 8, &ctx)
|
||||
.expect("completion should work");
|
||||
|
||||
let mut filled = InputBuffer::new();
|
||||
filled.insert('x');
|
||||
assert!(matches!(
|
||||
editor.handle_key(
|
||||
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
&mut filled,
|
||||
),
|
||||
super::EditorAction::Cancel
|
||||
));
|
||||
assert!(filled.as_str().is_empty());
|
||||
assert_eq!(start, 0);
|
||||
assert_eq!(
|
||||
matches
|
||||
.into_iter()
|
||||
.map(|candidate| candidate.replacement)
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["/model opus".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_slash_command_completion_requests() {
|
||||
let helper = SlashCommandHelper::new(vec!["/help".to_string()]);
|
||||
let history = DefaultHistory::new();
|
||||
let ctx = Context::new(&history);
|
||||
let (_, matches) = helper
|
||||
.complete("hello", 5, &ctx)
|
||||
.expect("completion should work");
|
||||
|
||||
assert!(matches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_current_buffer_through_highlighter() {
|
||||
let helper = SlashCommandHelper::new(Vec::new());
|
||||
let _ = helper.highlight("draft", 5);
|
||||
|
||||
assert_eq!(helper.current_line(), "draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_history_ignores_blank_entries() {
|
||||
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
|
||||
editor.push_history(" ");
|
||||
editor.push_history("/help");
|
||||
|
||||
assert_eq!(editor.editor.history().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_completions_replaces_and_normalizes_candidates() {
|
||||
let mut editor = LineEditor::new("> ", vec!["/help".to_string()]);
|
||||
editor.set_completions(vec![
|
||||
"/model opus".to_string(),
|
||||
"/model opus".to_string(),
|
||||
"status".to_string(),
|
||||
]);
|
||||
|
||||
let helper = editor.editor.helper().expect("helper should exist");
|
||||
assert_eq!(helper.completions, vec!["/model opus".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
+9546
-682
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
use std::fmt::Write as FmtWrite;
|
||||
use std::io::{self, Write};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
|
||||
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
|
||||
@@ -22,6 +20,7 @@ pub struct ColorTheme {
|
||||
link: Color,
|
||||
quote: Color,
|
||||
table_border: Color,
|
||||
code_block_border: Color,
|
||||
spinner_active: Color,
|
||||
spinner_done: Color,
|
||||
spinner_failed: Color,
|
||||
@@ -37,6 +36,7 @@ impl Default for ColorTheme {
|
||||
link: Color::Blue,
|
||||
quote: Color::DarkGrey,
|
||||
table_border: Color::DarkCyan,
|
||||
code_block_border: Color::DarkGrey,
|
||||
spinner_active: Color::Blue,
|
||||
spinner_done: Color::Green,
|
||||
spinner_failed: Color::Red,
|
||||
@@ -154,33 +154,64 @@ impl TableState {
|
||||
struct RenderState {
|
||||
emphasis: usize,
|
||||
strong: usize,
|
||||
heading_level: Option<u8>,
|
||||
quote: usize,
|
||||
list_stack: Vec<ListKind>,
|
||||
link_stack: Vec<LinkState>,
|
||||
table: Option<TableState>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct LinkState {
|
||||
destination: String,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
|
||||
let mut styled = text.to_string();
|
||||
if self.strong > 0 {
|
||||
styled = format!("{}", styled.bold().with(theme.strong));
|
||||
let mut style = text.stylize();
|
||||
|
||||
if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 {
|
||||
style = style.bold();
|
||||
}
|
||||
if self.emphasis > 0 {
|
||||
styled = format!("{}", styled.italic().with(theme.emphasis));
|
||||
style = style.italic();
|
||||
}
|
||||
|
||||
if let Some(level) = self.heading_level {
|
||||
style = match level {
|
||||
1 => style.with(theme.heading),
|
||||
2 => style.white(),
|
||||
3 => style.with(Color::Blue),
|
||||
_ => style.with(Color::Grey),
|
||||
};
|
||||
} else if self.strong > 0 {
|
||||
style = style.with(theme.strong);
|
||||
} else if self.emphasis > 0 {
|
||||
style = style.with(theme.emphasis);
|
||||
}
|
||||
|
||||
if self.quote > 0 {
|
||||
styled = format!("{}", styled.with(theme.quote));
|
||||
style = style.with(theme.quote);
|
||||
}
|
||||
styled
|
||||
|
||||
format!("{style}")
|
||||
}
|
||||
|
||||
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
|
||||
if let Some(table) = self.table.as_mut() {
|
||||
&mut table.current_cell
|
||||
fn append_raw(&mut self, output: &mut String, text: &str) {
|
||||
if let Some(link) = self.link_stack.last_mut() {
|
||||
link.text.push_str(text);
|
||||
} else if let Some(table) = self.table.as_mut() {
|
||||
table.current_cell.push_str(text);
|
||||
} else {
|
||||
output
|
||||
output.push_str(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) {
|
||||
let styled = self.style_text(text, theme);
|
||||
self.append_raw(output, &styled);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -218,13 +249,14 @@ impl TerminalRenderer {
|
||||
|
||||
#[must_use]
|
||||
pub fn render_markdown(&self, markdown: &str) -> String {
|
||||
let normalized = normalize_nested_fences(markdown);
|
||||
let mut output = String::new();
|
||||
let mut state = RenderState::default();
|
||||
let mut code_language = String::new();
|
||||
let mut code_buffer = String::new();
|
||||
let mut in_code_block = false;
|
||||
|
||||
for event in Parser::new_ext(markdown, Options::all()) {
|
||||
for event in Parser::new_ext(&normalized, Options::all()) {
|
||||
self.render_event(
|
||||
event,
|
||||
&mut state,
|
||||
@@ -238,6 +270,11 @@ impl TerminalRenderer {
|
||||
output.trim_end().to_string()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn markdown_to_ansi(&self, markdown: &str) -> String {
|
||||
self.render_markdown(markdown)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn render_event(
|
||||
&self,
|
||||
@@ -249,15 +286,21 @@ impl TerminalRenderer {
|
||||
in_code_block: &mut bool,
|
||||
) {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
|
||||
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
Self::start_heading(state, level as u8, output);
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
|
||||
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
|
||||
Event::End(TagEnd::BlockQuote(..)) => {
|
||||
state.quote = state.quote.saturating_sub(1);
|
||||
output.push('\n');
|
||||
}
|
||||
Event::End(TagEnd::Heading(..)) => {
|
||||
state.heading_level = None;
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
|
||||
state.capture_target_mut(output).push('\n');
|
||||
state.append_raw(output, "\n");
|
||||
}
|
||||
Event::Start(Tag::List(first_item)) => {
|
||||
let kind = match first_item {
|
||||
@@ -293,41 +336,52 @@ impl TerminalRenderer {
|
||||
Event::Code(code) => {
|
||||
let rendered =
|
||||
format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
|
||||
state.capture_target_mut(output).push_str(&rendered);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Rule => output.push_str("---\n"),
|
||||
Event::Text(text) => {
|
||||
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
|
||||
}
|
||||
Event::Html(html) | Event::InlineHtml(html) => {
|
||||
state.capture_target_mut(output).push_str(&html);
|
||||
state.append_raw(output, &html);
|
||||
}
|
||||
Event::FootnoteReference(reference) => {
|
||||
let _ = write!(state.capture_target_mut(output), "[{reference}]");
|
||||
state.append_raw(output, &format!("[{reference}]"));
|
||||
}
|
||||
Event::TaskListMarker(done) => {
|
||||
state
|
||||
.capture_target_mut(output)
|
||||
.push_str(if done { "[x] " } else { "[ ] " });
|
||||
state.append_raw(output, if done { "[x] " } else { "[ ] " });
|
||||
}
|
||||
Event::InlineMath(math) | Event::DisplayMath(math) => {
|
||||
state.capture_target_mut(output).push_str(&math);
|
||||
state.append_raw(output, &math);
|
||||
}
|
||||
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||
let rendered = format!(
|
||||
"{}",
|
||||
format!("[{dest_url}]")
|
||||
.underlined()
|
||||
.with(self.color_theme.link)
|
||||
);
|
||||
state.capture_target_mut(output).push_str(&rendered);
|
||||
state.link_stack.push(LinkState {
|
||||
destination: dest_url.to_string(),
|
||||
text: String::new(),
|
||||
});
|
||||
}
|
||||
Event::End(TagEnd::Link) => {
|
||||
if let Some(link) = state.link_stack.pop() {
|
||||
let label = if link.text.is_empty() {
|
||||
link.destination.clone()
|
||||
} else {
|
||||
link.text
|
||||
};
|
||||
let rendered = format!(
|
||||
"{}",
|
||||
format!("[{label}]({})", link.destination)
|
||||
.underlined()
|
||||
.with(self.color_theme.link)
|
||||
);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::Image { dest_url, .. }) => {
|
||||
let rendered = format!(
|
||||
"{}",
|
||||
format!("[image:{dest_url}]").with(self.color_theme.link)
|
||||
);
|
||||
state.capture_target_mut(output).push_str(&rendered);
|
||||
state.append_raw(output, &rendered);
|
||||
}
|
||||
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
|
||||
Event::End(TagEnd::Table) => {
|
||||
@@ -369,19 +423,15 @@ impl TerminalRenderer {
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
|
||||
| Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
|
||||
| Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_heading(&self, level: u8, output: &mut String) {
|
||||
output.push('\n');
|
||||
let prefix = match level {
|
||||
1 => "# ",
|
||||
2 => "## ",
|
||||
3 => "### ",
|
||||
_ => "#### ",
|
||||
};
|
||||
let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
|
||||
fn start_heading(state: &mut RenderState, level: u8, output: &mut String) {
|
||||
state.heading_level = Some(level);
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
fn start_quote(&self, state: &mut RenderState, output: &mut String) {
|
||||
@@ -405,20 +455,27 @@ impl TerminalRenderer {
|
||||
}
|
||||
|
||||
fn start_code_block(&self, code_language: &str, output: &mut String) {
|
||||
if !code_language.is_empty() {
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!("╭─ {code_language}").with(self.color_theme.heading)
|
||||
);
|
||||
}
|
||||
let label = if code_language.is_empty() {
|
||||
"code".to_string()
|
||||
} else {
|
||||
code_language.to_string()
|
||||
};
|
||||
let _ = writeln!(
|
||||
output,
|
||||
"{}",
|
||||
format!("╭─ {label}")
|
||||
.bold()
|
||||
.with(self.color_theme.code_block_border)
|
||||
);
|
||||
}
|
||||
|
||||
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
|
||||
output.push_str(&self.highlight_code(code_buffer, code_language));
|
||||
if !code_language.is_empty() {
|
||||
let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
|
||||
}
|
||||
let _ = write!(
|
||||
output,
|
||||
"{}",
|
||||
"╰─".bold().with(self.color_theme.code_block_border)
|
||||
);
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
|
||||
@@ -433,8 +490,7 @@ impl TerminalRenderer {
|
||||
if in_code_block {
|
||||
code_buffer.push_str(text);
|
||||
} else {
|
||||
let rendered = state.style_text(text, &self.color_theme);
|
||||
state.capture_target_mut(output).push_str(&rendered);
|
||||
state.append_styled(output, text, &self.color_theme);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -521,9 +577,10 @@ impl TerminalRenderer {
|
||||
for line in LinesWithEndings::from(code) {
|
||||
match syntax_highlighter.highlight_line(line, &self.syntax_set) {
|
||||
Ok(ranges) => {
|
||||
colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
|
||||
let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
|
||||
colored_output.push_str(&apply_code_block_background(&escaped));
|
||||
}
|
||||
Err(_) => colored_output.push_str(line),
|
||||
Err(_) => colored_output.push_str(&apply_code_block_background(line)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,16 +588,296 @@ impl TerminalRenderer {
|
||||
}
|
||||
|
||||
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
|
||||
let rendered_markdown = self.render_markdown(markdown);
|
||||
for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
|
||||
write!(out, "{chunk}")?;
|
||||
out.flush()?;
|
||||
thread::sleep(Duration::from_millis(8));
|
||||
let rendered_markdown = self.markdown_to_ansi(markdown);
|
||||
write!(out, "{rendered_markdown}")?;
|
||||
if !rendered_markdown.ends_with('\n') {
|
||||
writeln!(out)?;
|
||||
}
|
||||
writeln!(out)
|
||||
out.flush()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct MarkdownStreamState {
|
||||
pending: String,
|
||||
}
|
||||
|
||||
impl MarkdownStreamState {
|
||||
#[must_use]
|
||||
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
|
||||
self.pending.push_str(delta);
|
||||
let split = find_stream_safe_boundary(&self.pending)?;
|
||||
let ready = self.pending[..split].to_string();
|
||||
self.pending.drain(..split);
|
||||
Some(renderer.markdown_to_ansi(&ready))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option<String> {
|
||||
if self.pending.trim().is_empty() {
|
||||
self.pending.clear();
|
||||
None
|
||||
} else {
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
Some(renderer.markdown_to_ansi(&pending))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_code_block_background(line: &str) -> String {
|
||||
let trimmed = line.trim_end_matches('\n');
|
||||
let trailing_newline = if trimmed.len() == line.len() {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m");
|
||||
format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}")
|
||||
}
|
||||
|
||||
/// Pre-process raw markdown so that fenced code blocks whose body contains
|
||||
/// fence markers of equal or greater length are wrapped with a longer fence.
|
||||
///
|
||||
/// LLMs frequently emit triple-backtick code blocks that contain triple-backtick
|
||||
/// examples. CommonMark (and pulldown-cmark) treats the inner marker as the
|
||||
/// closing fence, breaking the render. This function detects the situation and
|
||||
/// upgrades the outer fence to use enough backticks (or tildes) that the inner
|
||||
/// markers become ordinary content.
|
||||
fn normalize_nested_fences(markdown: &str) -> String {
|
||||
// A fence line is either "labeled" (has an info string ⇒ always an opener)
|
||||
// or "bare" (no info string ⇒ could be opener or closer).
|
||||
#[derive(Debug, Clone)]
|
||||
struct FenceLine {
|
||||
char: char,
|
||||
len: usize,
|
||||
has_info: bool,
|
||||
indent: usize,
|
||||
}
|
||||
|
||||
fn parse_fence_line(line: &str) -> Option<FenceLine> {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
let indent = trimmed.chars().take_while(|c| *c == ' ').count();
|
||||
if indent > 3 {
|
||||
return None;
|
||||
}
|
||||
let rest = &trimmed[indent..];
|
||||
let ch = rest.chars().next()?;
|
||||
if ch != '`' && ch != '~' {
|
||||
return None;
|
||||
}
|
||||
let len = rest.chars().take_while(|c| *c == ch).count();
|
||||
if len < 3 {
|
||||
return None;
|
||||
}
|
||||
let after = &rest[len..];
|
||||
if ch == '`' && after.contains('`') {
|
||||
return None;
|
||||
}
|
||||
let has_info = !after.trim().is_empty();
|
||||
Some(FenceLine {
|
||||
char: ch,
|
||||
len,
|
||||
has_info,
|
||||
indent,
|
||||
})
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = markdown.split_inclusive('\n').collect();
|
||||
// Handle final line that may lack trailing newline.
|
||||
// split_inclusive already keeps the original chunks, including a
|
||||
// final chunk without '\n' if the input doesn't end with one.
|
||||
|
||||
// First pass: classify every line.
|
||||
let fence_info: Vec<Option<FenceLine>> = lines.iter().map(|l| parse_fence_line(l)).collect();
|
||||
|
||||
// Second pass: pair openers with closers using a stack, recording
|
||||
// (opener_idx, closer_idx) pairs plus the max fence length found between
|
||||
// them.
|
||||
struct StackEntry {
|
||||
line_idx: usize,
|
||||
fence: FenceLine,
|
||||
}
|
||||
|
||||
let mut stack: Vec<StackEntry> = Vec::new();
|
||||
// Paired blocks: (opener_line, closer_line, max_inner_fence_len)
|
||||
let mut pairs: Vec<(usize, usize, usize)> = Vec::new();
|
||||
|
||||
for (i, fi) in fence_info.iter().enumerate() {
|
||||
let Some(fl) = fi else { continue };
|
||||
|
||||
if fl.has_info {
|
||||
// Labeled fence ⇒ always an opener.
|
||||
stack.push(StackEntry {
|
||||
line_idx: i,
|
||||
fence: fl.clone(),
|
||||
});
|
||||
} else {
|
||||
// Bare fence ⇒ try to close the top of the stack if compatible.
|
||||
let closes_top = stack
|
||||
.last()
|
||||
.is_some_and(|top| top.fence.char == fl.char && fl.len >= top.fence.len);
|
||||
if closes_top {
|
||||
let opener = stack.pop().unwrap();
|
||||
// Find max fence length of any fence line strictly between
|
||||
// opener and closer (these are the nested fences).
|
||||
let inner_max = fence_info[opener.line_idx + 1..i]
|
||||
.iter()
|
||||
.filter_map(|fi| fi.as_ref().map(|f| f.len))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
pairs.push((opener.line_idx, i, inner_max));
|
||||
} else {
|
||||
// Treat as opener.
|
||||
stack.push(StackEntry {
|
||||
line_idx: i,
|
||||
fence: fl.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which lines need rewriting. A pair needs rewriting when
|
||||
// its opener length <= max inner fence length.
|
||||
struct Rewrite {
|
||||
char: char,
|
||||
new_len: usize,
|
||||
indent: usize,
|
||||
}
|
||||
let mut rewrites: std::collections::HashMap<usize, Rewrite> = std::collections::HashMap::new();
|
||||
|
||||
for (opener_idx, closer_idx, inner_max) in &pairs {
|
||||
let opener_fl = fence_info[*opener_idx].as_ref().unwrap();
|
||||
if opener_fl.len <= *inner_max {
|
||||
let new_len = inner_max + 1;
|
||||
let info_part = {
|
||||
let trimmed = lines[*opener_idx]
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r');
|
||||
let rest = &trimmed[opener_fl.indent..];
|
||||
rest[opener_fl.len..].to_string()
|
||||
};
|
||||
rewrites.insert(
|
||||
*opener_idx,
|
||||
Rewrite {
|
||||
char: opener_fl.char,
|
||||
new_len,
|
||||
indent: opener_fl.indent,
|
||||
},
|
||||
);
|
||||
let closer_fl = fence_info[*closer_idx].as_ref().unwrap();
|
||||
rewrites.insert(
|
||||
*closer_idx,
|
||||
Rewrite {
|
||||
char: closer_fl.char,
|
||||
new_len,
|
||||
indent: closer_fl.indent,
|
||||
},
|
||||
);
|
||||
// Store info string only in the opener; closer keeps the trailing
|
||||
// portion which is already handled through the original line.
|
||||
// Actually, we rebuild both lines from scratch below, including
|
||||
// the info string for the opener.
|
||||
let _ = info_part; // consumed in rebuild
|
||||
}
|
||||
}
|
||||
|
||||
if rewrites.is_empty() {
|
||||
return markdown.to_string();
|
||||
}
|
||||
|
||||
// Rebuild.
|
||||
let mut out = String::with_capacity(markdown.len() + rewrites.len() * 4);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if let Some(rw) = rewrites.get(&i) {
|
||||
let fence_str: String = std::iter::repeat(rw.char).take(rw.new_len).collect();
|
||||
let indent_str: String = std::iter::repeat(' ').take(rw.indent).collect();
|
||||
// Recover the original info string (if any) and trailing newline.
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
let fi = fence_info[i].as_ref().unwrap();
|
||||
let info = &trimmed[fi.indent + fi.len..];
|
||||
let trailing = &line[trimmed.len()..];
|
||||
out.push_str(&indent_str);
|
||||
out.push_str(&fence_str);
|
||||
out.push_str(info);
|
||||
out.push_str(trailing);
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
|
||||
let mut open_fence: Option<FenceMarker> = None;
|
||||
let mut last_boundary = None;
|
||||
|
||||
for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| {
|
||||
let start = *cursor;
|
||||
*cursor += line.len();
|
||||
Some((start, line))
|
||||
}) {
|
||||
let line_without_newline = line.trim_end_matches('\n');
|
||||
if let Some(opener) = open_fence {
|
||||
if line_closes_fence(line_without_newline, opener) {
|
||||
open_fence = None;
|
||||
last_boundary = Some(offset + line.len());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(opener) = parse_fence_opener(line_without_newline) {
|
||||
open_fence = Some(opener);
|
||||
continue;
|
||||
}
|
||||
|
||||
if line_without_newline.trim().is_empty() {
|
||||
last_boundary = Some(offset + line.len());
|
||||
}
|
||||
}
|
||||
|
||||
last_boundary
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct FenceMarker {
|
||||
character: char,
|
||||
length: usize,
|
||||
}
|
||||
|
||||
fn parse_fence_opener(line: &str) -> Option<FenceMarker> {
|
||||
let indent = line.chars().take_while(|c| *c == ' ').count();
|
||||
if indent > 3 {
|
||||
return None;
|
||||
}
|
||||
let rest = &line[indent..];
|
||||
let character = rest.chars().next()?;
|
||||
if character != '`' && character != '~' {
|
||||
return None;
|
||||
}
|
||||
let length = rest.chars().take_while(|c| *c == character).count();
|
||||
if length < 3 {
|
||||
return None;
|
||||
}
|
||||
let info_string = &rest[length..];
|
||||
if character == '`' && info_string.contains('`') {
|
||||
return None;
|
||||
}
|
||||
Some(FenceMarker { character, length })
|
||||
}
|
||||
|
||||
fn line_closes_fence(line: &str, opener: FenceMarker) -> bool {
|
||||
let indent = line.chars().take_while(|c| *c == ' ').count();
|
||||
if indent > 3 {
|
||||
return false;
|
||||
}
|
||||
let rest = &line[indent..];
|
||||
let length = rest.chars().take_while(|c| *c == opener.character).count();
|
||||
if length < opener.length {
|
||||
return false;
|
||||
}
|
||||
rest[length..].chars().all(|c| c == ' ' || c == '\t')
|
||||
}
|
||||
|
||||
fn visible_width(input: &str) -> usize {
|
||||
strip_ansi(input).chars().count()
|
||||
}
|
||||
@@ -569,7 +906,7 @@ fn strip_ansi(input: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{strip_ansi, Spinner, TerminalRenderer};
|
||||
use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer};
|
||||
|
||||
#[test]
|
||||
fn renders_markdown_with_styling_and_lists() {
|
||||
@@ -583,16 +920,28 @@ mod tests {
|
||||
assert!(markdown_output.contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_links_as_colored_markdown_labels() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
let markdown_output =
|
||||
terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now.");
|
||||
let plain_text = strip_ansi(&markdown_output);
|
||||
|
||||
assert!(plain_text.contains("[Claw](https://example.com/docs)"));
|
||||
assert!(markdown_output.contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlights_fenced_code_blocks() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
let markdown_output =
|
||||
terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```");
|
||||
terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```");
|
||||
let plain_text = strip_ansi(&markdown_output);
|
||||
|
||||
assert!(plain_text.contains("╭─ rust"));
|
||||
assert!(plain_text.contains("fn hi"));
|
||||
assert!(markdown_output.contains('\u{1b}'));
|
||||
assert!(markdown_output.contains("[48;5;236m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -623,6 +972,80 @@ mod tests {
|
||||
assert!(markdown_output.contains('\u{1b}'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_state_waits_for_complete_blocks() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
assert_eq!(state.push(&renderer, "# Heading"), None);
|
||||
let flushed = state
|
||||
.push(&renderer, "\n\nParagraph\n\n")
|
||||
.expect("completed block");
|
||||
let plain_text = strip_ansi(&flushed);
|
||||
assert!(plain_text.contains("Heading"));
|
||||
assert!(plain_text.contains("Paragraph"));
|
||||
|
||||
assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None);
|
||||
let code = state
|
||||
.push(&renderer, "```\n")
|
||||
.expect("closed code fence flushes");
|
||||
assert!(strip_ansi(&code).contains("fn main()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_state_holds_outer_fence_with_nested_inner_fence() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
assert_eq!(
|
||||
state.push(&renderer, "````markdown\n```rust\nfn inner() {}\n"),
|
||||
None,
|
||||
"inner triple backticks must not close the outer four-backtick fence"
|
||||
);
|
||||
assert_eq!(
|
||||
state.push(&renderer, "```\n"),
|
||||
None,
|
||||
"closing the inner fence must not flush the outer fence"
|
||||
);
|
||||
let flushed = state
|
||||
.push(&renderer, "````\n")
|
||||
.expect("closing the outer four-backtick fence flushes the buffered block");
|
||||
let plain_text = strip_ansi(&flushed);
|
||||
assert!(plain_text.contains("fn inner()"));
|
||||
assert!(plain_text.contains("```rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_state_distinguishes_backtick_and_tilde_fences() {
|
||||
let renderer = TerminalRenderer::new();
|
||||
let mut state = MarkdownStreamState::default();
|
||||
|
||||
assert_eq!(state.push(&renderer, "~~~text\n"), None);
|
||||
assert_eq!(
|
||||
state.push(&renderer, "```\nstill inside tilde fence\n"),
|
||||
None,
|
||||
"a backtick fence cannot close a tilde-opened fence"
|
||||
);
|
||||
assert_eq!(state.push(&renderer, "```\n"), None);
|
||||
let flushed = state
|
||||
.push(&renderer, "~~~\n")
|
||||
.expect("matching tilde marker closes the fence");
|
||||
let plain_text = strip_ansi(&flushed);
|
||||
assert!(plain_text.contains("still inside tilde fence"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_nested_fenced_code_block_preserves_inner_markers() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
let markdown_output =
|
||||
terminal_renderer.markdown_to_ansi("````markdown\n```rust\nfn nested() {}\n```\n````");
|
||||
let plain_text = strip_ansi(&markdown_output);
|
||||
|
||||
assert!(plain_text.contains("╭─ markdown"));
|
||||
assert!(plain_text.contains("```rust"));
|
||||
assert!(plain_text.contains("fn nested()"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_advances_frames() {
|
||||
let terminal_renderer = TerminalRenderer::new();
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use runtime::Session;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn status_command_applies_model_and_permission_mode_flags() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("status-flags");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
// when
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.args([
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"status",
|
||||
])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
// then
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Status"));
|
||||
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
||||
assert!(stdout.contains("Permission mode read-only"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_flag_loads_a_saved_session_and_dispatches_status() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-status");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = write_session(&temp_dir, "resume-status");
|
||||
|
||||
// when
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.args([
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/status",
|
||||
])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
// then
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Status"));
|
||||
assert!(stdout.contains("Messages 1"));
|
||||
assert!(stdout.contains("Session "));
|
||||
assert!(stdout.contains(session_path.to_str().expect("utf8 path")));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("slash-dispatch");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
// when
|
||||
let help_output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.arg("/help")
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
let unknown_output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.arg("/zstats")
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
// then
|
||||
assert_success(&help_output);
|
||||
let help_stdout = String::from_utf8(help_output.stdout).expect("stdout should be utf8");
|
||||
assert!(help_stdout.contains("Interactive slash commands:"));
|
||||
assert!(help_stdout.contains("/status"));
|
||||
|
||||
assert!(
|
||||
!unknown_output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&unknown_output.stdout),
|
||||
String::from_utf8_lossy(&unknown_output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8(unknown_output.stderr).expect("stderr should be utf8");
|
||||
assert!(stderr.contains("unknown slash command outside the REPL: /zstats"));
|
||||
assert!(stderr.contains("Did you mean"));
|
||||
assert!(stderr.contains("/status"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omc_namespaced_slash_commands_surface_a_targeted_compatibility_hint() {
|
||||
let temp_dir = unique_temp_dir("slash-dispatch-omc");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_claw"))
|
||||
.current_dir(&temp_dir)
|
||||
.arg("/oh-my-claudecode:hud")
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
|
||||
assert!(stderr.contains("unknown slash command outside the REPL: /oh-my-claudecode:hud"));
|
||||
assert!(stderr.contains("Claude Code/OMC plugin command"));
|
||||
assert!(stderr.contains("does not yet load plugin slash commands"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_command_loads_defaults_from_standard_config_locations() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("config-defaults");
|
||||
let config_home = temp_dir.join("home").join(".claw");
|
||||
fs::create_dir_all(temp_dir.join(".claw")).expect("project config dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("home config dir should exist");
|
||||
|
||||
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
|
||||
.expect("write user settings");
|
||||
fs::write(temp_dir.join(".claw.json"), r#"{"model":"sonnet"}"#)
|
||||
.expect("write project settings");
|
||||
fs::write(
|
||||
temp_dir.join(".claw").join("settings.local.json"),
|
||||
r#"{"model":"opus"}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
let session_path = write_session(&temp_dir, "config-defaults");
|
||||
|
||||
// when
|
||||
let output = command_in(&temp_dir)
|
||||
.env("CLAW_CONFIG_HOME", &config_home)
|
||||
.args([
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/config",
|
||||
"model",
|
||||
])
|
||||
.output()
|
||||
.expect("claw should launch");
|
||||
|
||||
// then
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Config"));
|
||||
assert!(stdout.contains("Loaded files 3"));
|
||||
assert!(stdout.contains("Merged section: model"));
|
||||
assert!(stdout.contains("opus"));
|
||||
assert!(stdout.contains(
|
||||
config_home
|
||||
.join("settings.json")
|
||||
.to_str()
|
||||
.expect("utf8 path")
|
||||
));
|
||||
assert!(stdout.contains(temp_dir.join(".claw.json").to_str().expect("utf8 path")));
|
||||
assert!(stdout.contains(
|
||||
temp_dir
|
||||
.join(".claw")
|
||||
.join("settings.local.json")
|
||||
.to_str()
|
||||
.expect("utf8 path")
|
||||
));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_command_runs_as_a_local_shell_entrypoint() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("doctor-entrypoint");
|
||||
let config_home = temp_dir.join("home").join(".claw");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
|
||||
// when
|
||||
let output = command_in(&temp_dir)
|
||||
.env("CLAW_CONFIG_HOME", &config_home)
|
||||
.env_remove("ANTHROPIC_API_KEY")
|
||||
.env_remove("ANTHROPIC_AUTH_TOKEN")
|
||||
.env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9")
|
||||
.arg("doctor")
|
||||
.output()
|
||||
.expect("claw doctor should launch");
|
||||
|
||||
// then
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Doctor"));
|
||||
assert!(stdout.contains("Auth"));
|
||||
assert!(stdout.contains("Config"));
|
||||
assert!(stdout.contains("Workspace"));
|
||||
assert!(stdout.contains("Sandbox"));
|
||||
assert!(!stdout.contains("Thinking"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_subcommand_help_does_not_fall_through_to_runtime_or_provider_calls() {
|
||||
let temp_dir = unique_temp_dir("subcommand-help");
|
||||
let config_home = temp_dir.join("home").join(".claw");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
|
||||
let doctor_help = command_in(&temp_dir)
|
||||
.env("CLAW_CONFIG_HOME", &config_home)
|
||||
.env_remove("ANTHROPIC_API_KEY")
|
||||
.env_remove("ANTHROPIC_AUTH_TOKEN")
|
||||
.env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9")
|
||||
.args(["doctor", "--help"])
|
||||
.output()
|
||||
.expect("doctor help should launch");
|
||||
let status_help = command_in(&temp_dir)
|
||||
.env("CLAW_CONFIG_HOME", &config_home)
|
||||
.env_remove("ANTHROPIC_API_KEY")
|
||||
.env_remove("ANTHROPIC_AUTH_TOKEN")
|
||||
.env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9")
|
||||
.args(["status", "--help"])
|
||||
.output()
|
||||
.expect("status help should launch");
|
||||
|
||||
assert_success(&doctor_help);
|
||||
let doctor_stdout = String::from_utf8(doctor_help.stdout).expect("stdout should be utf8");
|
||||
assert!(doctor_stdout.contains("Usage claw doctor"));
|
||||
assert!(doctor_stdout.contains("local-only health report"));
|
||||
assert!(!doctor_stdout.contains("Thinking"));
|
||||
|
||||
assert_success(&status_help);
|
||||
let status_stdout = String::from_utf8(status_help.stdout).expect("stdout should be utf8");
|
||||
assert!(status_stdout.contains("Usage claw status"));
|
||||
assert!(status_stdout.contains("local workspace snapshot"));
|
||||
assert!(!status_stdout.contains("Thinking"));
|
||||
|
||||
let doctor_stderr = String::from_utf8(doctor_help.stderr).expect("stderr should be utf8");
|
||||
let status_stderr = String::from_utf8(status_help.stderr).expect("stderr should be utf8");
|
||||
assert!(!doctor_stderr.contains("auth_unavailable"));
|
||||
assert!(!status_stderr.contains("auth_unavailable"));
|
||||
|
||||
fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
fn command_in(cwd: &Path) -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command.current_dir(cwd);
|
||||
command
|
||||
}
|
||||
|
||||
fn write_session(root: &Path, label: &str) -> PathBuf {
|
||||
let session_path = root.join(format!("{label}.jsonl"));
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text(format!("session fixture for {label}"))
|
||||
.expect("session write should succeed");
|
||||
session
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
session_path
|
||||
}
|
||||
|
||||
fn assert_success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn compact_flag_prints_only_final_assistant_text_without_tool_call_details() {
|
||||
// given a workspace pointed at the mock Anthropic service and a fixture file
|
||||
// that the read_file_roundtrip scenario will fetch through a tool call
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("compact-read-file");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(workspace.join("fixture.txt"), "alpha parity line\n").expect("fixture should write");
|
||||
|
||||
// when we run claw in compact text mode against a tool-using scenario
|
||||
let prompt = format!("{SCENARIO_PREFIX}read_file_roundtrip");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--allowedTools",
|
||||
"read_file",
|
||||
"--compact",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then the command exits successfully and stdout contains exactly the final
|
||||
// assistant text with no tool call IDs, JSON envelopes, or spinner output
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"compact run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let trimmed = stdout.trim_end_matches('\n');
|
||||
assert_eq!(
|
||||
trimmed, "read_file roundtrip complete: alpha parity line",
|
||||
"compact stdout should contain only the final assistant text"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("toolu_"),
|
||||
"compact stdout must not leak tool_use_id ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("\"tool_uses\""),
|
||||
"compact stdout must not leak json envelopes ({stdout:?})"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("Thinking"),
|
||||
"compact stdout must not include the spinner banner ({stdout:?})"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_flag_streaming_text_only_emits_final_message_text() {
|
||||
// given a workspace pointed at the mock Anthropic service running the
|
||||
// streaming_text scenario which only emits a single assistant text block
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let workspace = unique_temp_dir("compact-streaming-text");
|
||||
let config_home = workspace.join("config-home");
|
||||
let home = workspace.join("home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
// when we invoke claw with --compact for the streaming text scenario
|
||||
let prompt = format!("{SCENARIO_PREFIX}streaming_text");
|
||||
let output = run_claw(
|
||||
&workspace,
|
||||
&config_home,
|
||||
&home,
|
||||
&base_url,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"--compact",
|
||||
&prompt,
|
||||
],
|
||||
);
|
||||
|
||||
// then stdout should be exactly the assistant text followed by a newline
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"compact streaming run should succeed\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert_eq!(
|
||||
stdout, "Mock streaming says hello from the parity harness.\n",
|
||||
"compact streaming stdout should contain only the final assistant text"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
fn run_claw(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
home: &std::path::Path,
|
||||
base_url: &str,
|
||||
args: &[&str],
|
||||
) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.env("ANTHROPIC_API_KEY", "test-compact-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", config_home)
|
||||
.env("HOME", home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.args(args);
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-compact-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
#![cfg(unix)]
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn clean_env_cli_reaches_mock_anthropic_service_across_scripted_parity_scenarios() {
|
||||
let manifest_entries = load_scenario_manifest();
|
||||
let manifest = manifest_entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|entry| (entry.name.clone(), entry))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
|
||||
let server = runtime
|
||||
.block_on(MockAnthropicService::spawn())
|
||||
.expect("mock service should start");
|
||||
let base_url = server.base_url();
|
||||
|
||||
let cases = [
|
||||
ScenarioCase {
|
||||
name: "streaming_text",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_streaming_text,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "read_file_roundtrip",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("read_file"),
|
||||
stdin: None,
|
||||
prepare: prepare_read_fixture,
|
||||
assert: assert_read_file_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "grep_chunk_assembly",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("grep_search"),
|
||||
stdin: None,
|
||||
prepare: prepare_grep_fixture,
|
||||
assert: assert_grep_chunk_assembly,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "write_file_allowed",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: Some("write_file"),
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_write_file_allowed,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "write_file_denied",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("write_file"),
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_write_file_denied,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "multi_tool_turn_roundtrip",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: Some("read_file,grep_search"),
|
||||
stdin: None,
|
||||
prepare: prepare_multi_tool_fixture,
|
||||
assert: assert_multi_tool_turn_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "bash_stdout_roundtrip",
|
||||
permission_mode: "danger-full-access",
|
||||
allowed_tools: Some("bash"),
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_bash_stdout_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "bash_permission_prompt_approved",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: Some("bash"),
|
||||
stdin: Some("y\n"),
|
||||
prepare: prepare_noop,
|
||||
assert: assert_bash_permission_prompt_approved,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "bash_permission_prompt_denied",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: Some("bash"),
|
||||
stdin: Some("n\n"),
|
||||
prepare: prepare_noop,
|
||||
assert: assert_bash_permission_prompt_denied,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "plugin_tool_roundtrip",
|
||||
permission_mode: "workspace-write",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_plugin_fixture,
|
||||
assert: assert_plugin_tool_roundtrip,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "auto_compact_triggered",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_auto_compact_triggered,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
ScenarioCase {
|
||||
name: "token_cost_reporting",
|
||||
permission_mode: "read-only",
|
||||
allowed_tools: None,
|
||||
stdin: None,
|
||||
prepare: prepare_noop,
|
||||
assert: assert_token_cost_reporting,
|
||||
extra_env: None,
|
||||
resume_session: None,
|
||||
},
|
||||
];
|
||||
|
||||
let case_names = cases.iter().map(|case| case.name).collect::<Vec<_>>();
|
||||
let manifest_names = manifest_entries
|
||||
.iter()
|
||||
.map(|entry| entry.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
case_names, manifest_names,
|
||||
"manifest and harness cases must stay aligned"
|
||||
);
|
||||
|
||||
let mut scenario_reports = Vec::new();
|
||||
|
||||
for case in cases {
|
||||
let workspace = HarnessWorkspace::new(unique_temp_dir(case.name));
|
||||
workspace.create().expect("workspace should exist");
|
||||
(case.prepare)(&workspace);
|
||||
|
||||
let run = run_case(case, &workspace, &base_url);
|
||||
(case.assert)(&workspace, &run);
|
||||
|
||||
let manifest_entry = manifest
|
||||
.get(case.name)
|
||||
.unwrap_or_else(|| panic!("missing manifest entry for {}", case.name));
|
||||
scenario_reports.push(build_scenario_report(
|
||||
case.name,
|
||||
manifest_entry,
|
||||
&run.response,
|
||||
));
|
||||
|
||||
fs::remove_dir_all(&workspace.root).expect("workspace cleanup should succeed");
|
||||
}
|
||||
|
||||
let captured = runtime.block_on(server.captured_requests());
|
||||
// After `be561bf` added count_tokens preflight, each turn sends an
|
||||
// extra POST to `/v1/messages/count_tokens` before the messages POST.
|
||||
// The original count (21) assumed messages-only requests. We now
|
||||
// filter to `/v1/messages` and verify that subset matches the original
|
||||
// scenario expectation.
|
||||
let messages_only: Vec<_> = captured
|
||||
.iter()
|
||||
.filter(|r| r.path == "/v1/messages")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
messages_only.len(),
|
||||
21,
|
||||
"twelve scenarios should produce twenty-one /v1/messages requests (total captured: {}, includes count_tokens)",
|
||||
captured.len()
|
||||
);
|
||||
assert!(messages_only.iter().all(|request| request.stream));
|
||||
|
||||
let scenarios = messages_only
|
||||
.iter()
|
||||
.map(|request| request.scenario.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
scenarios,
|
||||
vec![
|
||||
"streaming_text",
|
||||
"read_file_roundtrip",
|
||||
"read_file_roundtrip",
|
||||
"grep_chunk_assembly",
|
||||
"grep_chunk_assembly",
|
||||
"write_file_allowed",
|
||||
"write_file_allowed",
|
||||
"write_file_denied",
|
||||
"write_file_denied",
|
||||
"multi_tool_turn_roundtrip",
|
||||
"multi_tool_turn_roundtrip",
|
||||
"bash_stdout_roundtrip",
|
||||
"bash_stdout_roundtrip",
|
||||
"bash_permission_prompt_approved",
|
||||
"bash_permission_prompt_approved",
|
||||
"bash_permission_prompt_denied",
|
||||
"bash_permission_prompt_denied",
|
||||
"plugin_tool_roundtrip",
|
||||
"plugin_tool_roundtrip",
|
||||
"auto_compact_triggered",
|
||||
"token_cost_reporting",
|
||||
]
|
||||
);
|
||||
|
||||
let mut request_counts = BTreeMap::new();
|
||||
for request in &captured {
|
||||
*request_counts
|
||||
.entry(request.scenario.as_str())
|
||||
.or_insert(0_usize) += 1;
|
||||
}
|
||||
for report in &mut scenario_reports {
|
||||
report.request_count = *request_counts
|
||||
.get(report.name.as_str())
|
||||
.unwrap_or_else(|| panic!("missing request count for {}", report.name));
|
||||
}
|
||||
|
||||
maybe_write_report(&scenario_reports);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ScenarioCase {
|
||||
name: &'static str,
|
||||
permission_mode: &'static str,
|
||||
allowed_tools: Option<&'static str>,
|
||||
stdin: Option<&'static str>,
|
||||
prepare: fn(&HarnessWorkspace),
|
||||
assert: fn(&HarnessWorkspace, &ScenarioRun),
|
||||
extra_env: Option<(&'static str, &'static str)>,
|
||||
resume_session: Option<&'static str>,
|
||||
}
|
||||
|
||||
struct HarnessWorkspace {
|
||||
root: PathBuf,
|
||||
config_home: PathBuf,
|
||||
home: PathBuf,
|
||||
}
|
||||
|
||||
impl HarnessWorkspace {
|
||||
fn new(root: PathBuf) -> Self {
|
||||
Self {
|
||||
config_home: root.join("config-home"),
|
||||
home: root.join("home"),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
fn create(&self) -> std::io::Result<()> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
fs::create_dir_all(&self.config_home)?;
|
||||
fs::create_dir_all(&self.home)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct ScenarioRun {
|
||||
response: Value,
|
||||
stdout: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScenarioManifestEntry {
|
||||
name: String,
|
||||
category: String,
|
||||
description: String,
|
||||
parity_refs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScenarioReport {
|
||||
name: String,
|
||||
category: String,
|
||||
description: String,
|
||||
parity_refs: Vec<String>,
|
||||
iterations: u64,
|
||||
request_count: usize,
|
||||
tool_uses: Vec<String>,
|
||||
tool_error_count: usize,
|
||||
final_message: String,
|
||||
}
|
||||
|
||||
fn run_case(case: ScenarioCase, workspace: &HarnessWorkspace, base_url: &str) -> ScenarioRun {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command
|
||||
.current_dir(&workspace.root)
|
||||
.env_clear()
|
||||
.env("ANTHROPIC_API_KEY", "test-parity-key")
|
||||
.env("ANTHROPIC_BASE_URL", base_url)
|
||||
.env("CLAW_CONFIG_HOME", &workspace.config_home)
|
||||
.env("HOME", &workspace.home)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.args([
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
case.permission_mode,
|
||||
"--output-format=json",
|
||||
]);
|
||||
|
||||
if let Some(allowed_tools) = case.allowed_tools {
|
||||
command.args(["--allowedTools", allowed_tools]);
|
||||
}
|
||||
if let Some((key, value)) = case.extra_env {
|
||||
command.env(key, value);
|
||||
}
|
||||
if let Some(session_id) = case.resume_session {
|
||||
command.args(["--resume", session_id]);
|
||||
}
|
||||
|
||||
let prompt = format!("{SCENARIO_PREFIX}{}", case.name);
|
||||
command.arg(prompt);
|
||||
|
||||
let output = if let Some(stdin) = case.stdin {
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("claw should launch");
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin should be piped")
|
||||
.write_all(stdin.as_bytes())
|
||||
.expect("stdin should write");
|
||||
child.wait_with_output().expect("claw should finish")
|
||||
} else {
|
||||
command.output().expect("claw should launch")
|
||||
};
|
||||
|
||||
assert_success(&output);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
ScenarioRun {
|
||||
response: parse_json_output(&stdout),
|
||||
stdout,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn prepare_auto_compact_fixture(workspace: &HarnessWorkspace) {
|
||||
let sessions_dir = workspace.root.join(".claw").join("sessions");
|
||||
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
||||
|
||||
// Write a pre-seeded session with 6 messages so auto-compact can remove them
|
||||
let session_id = "parity-auto-compact-seed";
|
||||
let session_jsonl = r#"{"type":"session_meta","version":3,"session_id":"parity-auto-compact-seed","created_at_ms":1743724800000,"updated_at_ms":1743724800000}
|
||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step one of the parity scenario"}]}}
|
||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step one"}]}}
|
||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step two of the parity scenario"}]}}
|
||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step two"}]}}
|
||||
{"type":"message","message":{"role":"user","blocks":[{"type":"text","text":"step three of the parity scenario"}]}}
|
||||
{"type":"message","message":{"role":"assistant","blocks":[{"type":"text","text":"acknowledged step three"}]}}
|
||||
"#;
|
||||
fs::write(
|
||||
sessions_dir.join(format!("{session_id}.jsonl")),
|
||||
session_jsonl,
|
||||
)
|
||||
.expect("pre-seeded session should write");
|
||||
}
|
||||
|
||||
fn prepare_noop(_: &HarnessWorkspace) {}
|
||||
|
||||
fn prepare_read_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::write(workspace.root.join("fixture.txt"), "alpha parity line\n")
|
||||
.expect("fixture should write");
|
||||
}
|
||||
|
||||
fn prepare_grep_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::write(
|
||||
workspace.root.join("fixture.txt"),
|
||||
"alpha parity line\nbeta line\ngamma parity line\n",
|
||||
)
|
||||
.expect("grep fixture should write");
|
||||
}
|
||||
|
||||
fn prepare_multi_tool_fixture(workspace: &HarnessWorkspace) {
|
||||
fs::write(
|
||||
workspace.root.join("fixture.txt"),
|
||||
"alpha parity line\nbeta line\ngamma parity line\n",
|
||||
)
|
||||
.expect("multi tool fixture should write");
|
||||
}
|
||||
|
||||
fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
|
||||
let plugin_root = workspace
|
||||
.root
|
||||
.join("external-plugins")
|
||||
.join("parity-plugin");
|
||||
let tool_dir = plugin_root.join("tools");
|
||||
let manifest_dir = plugin_root.join(".claude-plugin");
|
||||
fs::create_dir_all(&tool_dir).expect("plugin tools dir");
|
||||
fs::create_dir_all(&manifest_dir).expect("plugin manifest dir");
|
||||
|
||||
let script_path = tool_dir.join("echo-json.sh");
|
||||
fs::write(
|
||||
&script_path,
|
||||
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
|
||||
)
|
||||
.expect("plugin script should write");
|
||||
let mut permissions = fs::metadata(&script_path)
|
||||
.expect("plugin script metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
|
||||
|
||||
fs::write(
|
||||
manifest_dir.join("plugin.json"),
|
||||
r#"{
|
||||
"name": "parity-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "mock parity plugin",
|
||||
"tools": [
|
||||
{
|
||||
"name": "plugin_echo",
|
||||
"description": "Echo JSON input",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" }
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"command": "./tools/echo-json.sh",
|
||||
"requiredPermission": "workspace-write"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("plugin manifest should write");
|
||||
|
||||
fs::write(
|
||||
workspace.config_home.join("settings.json"),
|
||||
json!({
|
||||
"enabledPlugins": {
|
||||
"parity-plugin@external": true
|
||||
},
|
||||
"plugins": {
|
||||
"externalDirectories": [plugin_root.parent().expect("plugin parent").display().to_string()]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("plugin settings should write");
|
||||
}
|
||||
|
||||
fn assert_streaming_text(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(
|
||||
run.response["message"],
|
||||
Value::String("Mock streaming says hello from the parity harness.".to_string())
|
||||
);
|
||||
assert_eq!(run.response["iterations"], Value::from(1));
|
||||
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
|
||||
assert_eq!(run.response["tool_results"], Value::Array(Vec::new()));
|
||||
}
|
||||
|
||||
fn assert_read_file_roundtrip(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("read_file".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["input"],
|
||||
Value::String(r#"{"path":"fixture.txt"}"#.to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("alpha parity line"));
|
||||
let output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(output.contains(&workspace.root.join("fixture.txt").display().to_string()));
|
||||
assert!(output.contains("alpha parity line"));
|
||||
}
|
||||
|
||||
fn assert_grep_chunk_assembly(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("grep_search".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["input"],
|
||||
Value::String(
|
||||
r#"{"pattern":"parity","path":"fixture.txt","output_mode":"count"}"#.to_string()
|
||||
)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("2 occurrences"));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_write_file_allowed(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("write_file".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("generated/output.txt"));
|
||||
let generated = workspace.root.join("generated").join("output.txt");
|
||||
let contents = fs::read_to_string(&generated).expect("generated file should exist");
|
||||
assert_eq!(contents, "created by mock service\n");
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_write_file_denied(workspace: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("write_file".to_string())
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(tool_output.contains("requires workspace-write permission"));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("denied as expected"));
|
||||
assert!(!workspace.root.join("generated").join("denied.txt").exists());
|
||||
}
|
||||
|
||||
fn assert_multi_tool_turn_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
let tool_uses = run.response["tool_uses"]
|
||||
.as_array()
|
||||
.expect("tool uses array");
|
||||
assert_eq!(
|
||||
tool_uses.len(),
|
||||
2,
|
||||
"expected two tool uses in a single turn"
|
||||
);
|
||||
assert_eq!(tool_uses[0]["name"], Value::String("read_file".to_string()));
|
||||
assert_eq!(
|
||||
tool_uses[1]["name"],
|
||||
Value::String("grep_search".to_string())
|
||||
);
|
||||
let tool_results = run.response["tool_results"]
|
||||
.as_array()
|
||||
.expect("tool results array");
|
||||
assert_eq!(
|
||||
tool_results.len(),
|
||||
2,
|
||||
"expected two tool results in a single turn"
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("alpha parity line"));
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("2 occurrences"));
|
||||
}
|
||||
|
||||
fn assert_bash_stdout_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("bash".to_string())
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
|
||||
assert_eq!(
|
||||
parsed["stdout"],
|
||||
Value::String("alpha from bash".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("alpha from bash"));
|
||||
}
|
||||
|
||||
fn assert_bash_permission_prompt_approved(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert!(run.stdout.contains("Permission approval required"));
|
||||
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(false)
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("bash output json");
|
||||
assert_eq!(
|
||||
parsed["stdout"],
|
||||
Value::String("approved via prompt".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("approved and executed"));
|
||||
}
|
||||
|
||||
fn assert_bash_permission_prompt_denied(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert!(run.stdout.contains("Permission approval required"));
|
||||
assert!(run.stdout.contains("Approve this tool call? [y/N]:"));
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
assert!(tool_output.contains("denied by user approval prompt"));
|
||||
assert_eq!(
|
||||
run.response["tool_results"][0]["is_error"],
|
||||
Value::Bool(true)
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("denied as expected"));
|
||||
}
|
||||
|
||||
fn assert_plugin_tool_roundtrip(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(2));
|
||||
assert_eq!(
|
||||
run.response["tool_uses"][0]["name"],
|
||||
Value::String("plugin_echo".to_string())
|
||||
);
|
||||
let tool_output = run.response["tool_results"][0]["output"]
|
||||
.as_str()
|
||||
.expect("tool output");
|
||||
let parsed: Value = serde_json::from_str(tool_output).expect("plugin output json");
|
||||
assert_eq!(
|
||||
parsed["plugin"],
|
||||
Value::String("parity-plugin@external".to_string())
|
||||
);
|
||||
assert_eq!(parsed["tool"], Value::String("plugin_echo".to_string()));
|
||||
assert_eq!(
|
||||
parsed["input"]["message"],
|
||||
Value::String("hello from plugin parity".to_string())
|
||||
);
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("hello from plugin parity"));
|
||||
}
|
||||
|
||||
fn assert_auto_compact_triggered(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
// Validates that the auto_compaction field is present in JSON output (format parity).
|
||||
// Trigger behavior is covered by conversation::tests::auto_compacts_when_cumulative_input_threshold_is_crossed.
|
||||
assert_eq!(run.response["iterations"], Value::from(1));
|
||||
assert_eq!(run.response["tool_uses"], Value::Array(Vec::new()));
|
||||
assert!(
|
||||
run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("auto compact parity complete."),
|
||||
"expected auto compact message in response"
|
||||
);
|
||||
// auto_compaction key must be present in JSON (may be null for below-threshold sessions)
|
||||
assert!(
|
||||
run.response
|
||||
.as_object()
|
||||
.expect("response object")
|
||||
.contains_key("auto_compaction"),
|
||||
"auto_compaction key must be present in JSON output"
|
||||
);
|
||||
// Verify input_tokens field reflects the large mock token counts
|
||||
let input_tokens = run.response["usage"]["input_tokens"]
|
||||
.as_u64()
|
||||
.expect("input_tokens should be present");
|
||||
assert!(
|
||||
input_tokens >= 50_000,
|
||||
"input_tokens should reflect mock service value (got {input_tokens})"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) {
|
||||
assert_eq!(run.response["iterations"], Value::from(1));
|
||||
assert!(run.response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.contains("token cost reporting parity complete."),);
|
||||
let usage = &run.response["usage"];
|
||||
assert!(
|
||||
usage["input_tokens"].as_u64().unwrap_or(0) > 0,
|
||||
"input_tokens should be non-zero"
|
||||
);
|
||||
assert!(
|
||||
usage["output_tokens"].as_u64().unwrap_or(0) > 0,
|
||||
"output_tokens should be non-zero"
|
||||
);
|
||||
assert!(
|
||||
run.response["estimated_cost"]
|
||||
.as_str()
|
||||
.is_some_and(|cost| cost.starts_with('$')),
|
||||
"estimated_cost should be a dollar-prefixed string"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_json_output(stdout: &str) -> Value {
|
||||
if let Some(index) = stdout.rfind("{\"auto_compaction\"") {
|
||||
return serde_json::from_str(&stdout[index..]).unwrap_or_else(|error| {
|
||||
panic!("failed to parse JSON response from stdout: {error}\n{stdout}")
|
||||
});
|
||||
}
|
||||
|
||||
stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('{') && trimmed.ends_with('}') {
|
||||
serde_json::from_str(trimmed).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no JSON response line found in stdout:\n{stdout}"))
|
||||
}
|
||||
|
||||
fn build_scenario_report(
|
||||
name: &str,
|
||||
manifest_entry: &ScenarioManifestEntry,
|
||||
response: &Value,
|
||||
) -> ScenarioReport {
|
||||
ScenarioReport {
|
||||
name: name.to_string(),
|
||||
category: manifest_entry.category.clone(),
|
||||
description: manifest_entry.description.clone(),
|
||||
parity_refs: manifest_entry.parity_refs.clone(),
|
||||
iterations: response["iterations"]
|
||||
.as_u64()
|
||||
.expect("iterations should exist"),
|
||||
request_count: 0,
|
||||
tool_uses: response["tool_uses"]
|
||||
.as_array()
|
||||
.expect("tool uses array")
|
||||
.iter()
|
||||
.filter_map(|value| value["name"].as_str().map(ToOwned::to_owned))
|
||||
.collect(),
|
||||
tool_error_count: response["tool_results"]
|
||||
.as_array()
|
||||
.expect("tool results array")
|
||||
.iter()
|
||||
.filter(|value| value["is_error"].as_bool().unwrap_or(false))
|
||||
.count(),
|
||||
final_message: response["message"]
|
||||
.as_str()
|
||||
.expect("message text")
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_write_report(reports: &[ScenarioReport]) {
|
||||
let Some(path) = std::env::var_os("MOCK_PARITY_REPORT_PATH") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let payload = json!({
|
||||
"scenario_count": reports.len(),
|
||||
"request_count": reports.iter().map(|report| report.request_count).sum::<usize>(),
|
||||
"scenarios": reports.iter().map(scenario_report_json).collect::<Vec<_>>(),
|
||||
});
|
||||
fs::write(
|
||||
path,
|
||||
serde_json::to_vec_pretty(&payload).expect("report json should serialize"),
|
||||
)
|
||||
.expect("report should write");
|
||||
}
|
||||
|
||||
fn load_scenario_manifest() -> Vec<ScenarioManifestEntry> {
|
||||
let manifest_path =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../mock_parity_scenarios.json");
|
||||
let manifest = fs::read_to_string(&manifest_path).expect("scenario manifest should exist");
|
||||
serde_json::from_str::<Vec<Value>>(&manifest)
|
||||
.expect("scenario manifest should parse")
|
||||
.into_iter()
|
||||
.map(|entry| ScenarioManifestEntry {
|
||||
name: entry["name"]
|
||||
.as_str()
|
||||
.expect("scenario name should be a string")
|
||||
.to_string(),
|
||||
category: entry["category"]
|
||||
.as_str()
|
||||
.expect("scenario category should be a string")
|
||||
.to_string(),
|
||||
description: entry["description"]
|
||||
.as_str()
|
||||
.expect("scenario description should be a string")
|
||||
.to_string(),
|
||||
parity_refs: entry["parity_refs"]
|
||||
.as_array()
|
||||
.expect("parity refs should be an array")
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.expect("parity ref should be a string")
|
||||
.to_string()
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn scenario_report_json(report: &ScenarioReport) -> Value {
|
||||
json!({
|
||||
"name": report.name,
|
||||
"category": report.category,
|
||||
"description": report.description,
|
||||
"parity_refs": report.parity_refs,
|
||||
"iterations": report.iterations,
|
||||
"request_count": report.request_count,
|
||||
"tool_uses": report.tool_uses,
|
||||
"tool_error_count": report.tool_error_count,
|
||||
"final_message": report.final_message,
|
||||
})
|
||||
}
|
||||
|
||||
fn assert_success(output: &Output) {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-mock-parity-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn help_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("help-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert!(parsed["message"]
|
||||
.as_str()
|
||||
.expect("help text")
|
||||
.contains("Usage:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_emits_json_when_requested() {
|
||||
let root = unique_temp_dir("version-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_sandbox_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("status-sandbox-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let status = assert_json_command(&root, &["--output-format", "json", "status"]);
|
||||
assert_eq!(status["kind"], "status");
|
||||
assert!(status["workspace"]["cwd"].as_str().is_some());
|
||||
|
||||
let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]);
|
||||
assert_eq!(sandbox["kind"], "sandbox");
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_commands_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("inventory-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let isolated_home = root.join("home");
|
||||
let isolated_config = root.join("config-home");
|
||||
let isolated_codex = root.join("codex-home");
|
||||
fs::create_dir_all(&isolated_home).expect("isolated home should exist");
|
||||
|
||||
let agents = assert_json_command_with_env(
|
||||
&root,
|
||||
&["--output-format", "json", "agents"],
|
||||
&[
|
||||
("HOME", isolated_home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(agents["kind"], "agents");
|
||||
assert_eq!(agents["action"], "list");
|
||||
assert_eq!(agents["count"], 0);
|
||||
assert_eq!(agents["summary"]["active"], 0);
|
||||
assert!(agents["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
assert_eq!(mcp["action"], "list");
|
||||
|
||||
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
|
||||
assert_eq!(skills["kind"], "skills");
|
||||
assert_eq!(skills["action"], "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_command_emits_structured_agent_entries_when_requested() {
|
||||
let root = unique_temp_dir("agents-json-populated");
|
||||
let workspace = root.join("workspace");
|
||||
let project_agents = workspace.join(".codex").join("agents");
|
||||
let home = root.join("home");
|
||||
let user_agents = home.join(".codex").join("agents");
|
||||
let isolated_config = root.join("config-home");
|
||||
let isolated_codex = root.join("codex-home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"planner",
|
||||
"Project planner",
|
||||
"gpt-5.4",
|
||||
"medium",
|
||||
);
|
||||
write_agent(
|
||||
&project_agents,
|
||||
"verifier",
|
||||
"Verification agent",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
write_agent(
|
||||
&user_agents,
|
||||
"planner",
|
||||
"User planner",
|
||||
"gpt-5.4-mini",
|
||||
"high",
|
||||
);
|
||||
|
||||
let parsed = assert_json_command_with_env(
|
||||
&workspace,
|
||||
&["--output-format", "json", "agents"],
|
||||
&[
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(parsed["kind"], "agents");
|
||||
assert_eq!(parsed["action"], "list");
|
||||
assert_eq!(parsed["count"], 3);
|
||||
assert_eq!(parsed["summary"]["active"], 2);
|
||||
assert_eq!(parsed["summary"]["shadowed"], 1);
|
||||
assert_eq!(parsed["agents"][0]["name"], "planner");
|
||||
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
|
||||
assert_eq!(parsed["agents"][0]["active"], true);
|
||||
assert_eq!(parsed["agents"][1]["name"], "verifier");
|
||||
assert_eq!(parsed["agents"][2]["name"], "planner");
|
||||
assert_eq!(parsed["agents"][2]["active"], false);
|
||||
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]);
|
||||
assert_eq!(plan["kind"], "bootstrap-plan");
|
||||
assert!(plan["phases"].as_array().expect("phases").len() > 1);
|
||||
|
||||
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
|
||||
assert_eq!(prompt["kind"], "system-prompt");
|
||||
assert!(prompt["message"]
|
||||
.as_str()
|
||||
.expect("prompt text")
|
||||
.contains("interactive agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dump_manifests_and_init_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("manifest-init-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let upstream = write_upstream_fixture(&root);
|
||||
let manifests = assert_json_command_with_env(
|
||||
&root,
|
||||
&["--output-format", "json", "dump-manifests"],
|
||||
&[(
|
||||
"CLAUDE_CODE_UPSTREAM",
|
||||
upstream.to_str().expect("utf8 upstream"),
|
||||
)],
|
||||
);
|
||||
assert_eq!(manifests["kind"], "dump-manifests");
|
||||
assert_eq!(manifests["commands"], 1);
|
||||
assert_eq!(manifests["tools"], 1);
|
||||
|
||||
let workspace = root.join("workspace");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
|
||||
assert_eq!(init["kind"], "init");
|
||||
assert!(workspace.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("doctor-resume-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]);
|
||||
assert_eq!(doctor["kind"], "doctor");
|
||||
assert!(doctor["message"].is_string());
|
||||
let summary = doctor["summary"].as_object().expect("doctor summary");
|
||||
assert!(summary["ok"].as_u64().is_some());
|
||||
assert!(summary["warnings"].as_u64().is_some());
|
||||
assert!(summary["failures"].as_u64().is_some());
|
||||
|
||||
let checks = doctor["checks"].as_array().expect("doctor checks");
|
||||
assert_eq!(checks.len(), 5);
|
||||
let check_names = checks
|
||||
.iter()
|
||||
.map(|check| {
|
||||
assert!(check["status"].as_str().is_some());
|
||||
assert!(check["summary"].as_str().is_some());
|
||||
assert!(check["details"].is_array());
|
||||
check["name"].as_str().expect("doctor check name")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
check_names,
|
||||
vec!["auth", "config", "workspace", "sandbox", "system"]
|
||||
);
|
||||
|
||||
let workspace = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "workspace")
|
||||
.expect("workspace check");
|
||||
assert!(workspace["cwd"].as_str().is_some());
|
||||
assert!(workspace["in_git_repo"].is_boolean());
|
||||
|
||||
let sandbox = checks
|
||||
.iter()
|
||||
.find(|check| check["name"] == "sandbox")
|
||||
.expect("sandbox check");
|
||||
assert!(sandbox["filesystem_mode"].as_str().is_some());
|
||||
assert!(sandbox["enabled"].is_boolean());
|
||||
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
|
||||
)
|
||||
.expect("session should write");
|
||||
let resumed = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/status",
|
||||
],
|
||||
);
|
||||
assert_eq!(resumed["kind"], "status");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
assert!(resumed["model"].is_null());
|
||||
assert_eq!(resumed["usage"]["messages"], 1);
|
||||
assert!(resumed["workspace"]["cwd"].as_str().is_some());
|
||||
assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("resume-inventory-json");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
|
||||
)
|
||||
.expect("session should write");
|
||||
|
||||
let mcp = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/mcp",
|
||||
],
|
||||
&[
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
],
|
||||
);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
assert_eq!(mcp["action"], "list");
|
||||
assert!(mcp["servers"].is_array());
|
||||
|
||||
let skills = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/skills",
|
||||
],
|
||||
&[
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
],
|
||||
);
|
||||
assert_eq!(skills["kind"], "skills");
|
||||
assert_eq!(skills["action"], "list");
|
||||
assert!(skills["summary"]["total"].is_number());
|
||||
assert!(skills["skills"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_version_and_init_emit_structured_json_when_requested() {
|
||||
let root = unique_temp_dir("resume-version-init-json");
|
||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||
|
||||
let session_path = root.join("session.jsonl");
|
||||
fs::write(
|
||||
&session_path,
|
||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
|
||||
)
|
||||
.expect("session should write");
|
||||
|
||||
let version = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/version",
|
||||
],
|
||||
);
|
||||
assert_eq!(version["kind"], "version");
|
||||
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let init = assert_json_command(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 session path"),
|
||||
"/init",
|
||||
],
|
||||
);
|
||||
assert_eq!(init["kind"], "init");
|
||||
assert!(root.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value {
|
||||
let output = run_claw(current_dir, args, envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid json")
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command.current_dir(current_dir).args(args);
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
let upstream = root.join("claw-code");
|
||||
let src = upstream.join("src");
|
||||
let entrypoints = src.join("entrypoints");
|
||||
fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist");
|
||||
fs::write(
|
||||
src.join("commands.ts"),
|
||||
"import FooCommand from './commands/foo'\n",
|
||||
)
|
||||
.expect("commands fixture should write");
|
||||
fs::write(
|
||||
src.join("tools.ts"),
|
||||
"import ReadTool from './tools/read'\n",
|
||||
)
|
||||
.expect("tools fixture should write");
|
||||
fs::write(
|
||||
entrypoints.join("cli.tsx"),
|
||||
"if (args[0] === '--version') {}\nstartupProfiler()\n",
|
||||
)
|
||||
.expect("cli fixture should write");
|
||||
upstream
|
||||
}
|
||||
|
||||
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
||||
fs::create_dir_all(root).expect("agent root should exist");
|
||||
fs::write(
|
||||
root.join(format!("{name}.toml")),
|
||||
format!(
|
||||
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
|
||||
),
|
||||
)
|
||||
.expect("agent fixture should write");
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-output-format-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use runtime::ContentBlock;
|
||||
use runtime::Session;
|
||||
use serde_json::Value;
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[test]
|
||||
fn resumed_binary_accepts_slash_commands_with_arguments() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-slash-commands");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
let export_path = temp_dir.join("notes.txt");
|
||||
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text("ship the slash command harness")
|
||||
.expect("session write should succeed");
|
||||
session
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/export",
|
||||
export_path.to_str().expect("utf8 path"),
|
||||
"/clear",
|
||||
"--confirm",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Export"));
|
||||
assert!(stdout.contains("wrote transcript"));
|
||||
assert!(stdout.contains(export_path.to_str().expect("utf8 path")));
|
||||
assert!(stdout.contains("Session cleared"));
|
||||
assert!(stdout.contains("Mode resumed session reset"));
|
||||
assert!(stdout.contains("Previous session"));
|
||||
assert!(stdout.contains("Resume previous claw --resume"));
|
||||
assert!(stdout.contains("Backup "));
|
||||
assert!(stdout.contains("Session file "));
|
||||
|
||||
let export = fs::read_to_string(&export_path).expect("export file should exist");
|
||||
assert!(export.contains("# Conversation Export"));
|
||||
assert!(export.contains("ship the slash command harness"));
|
||||
|
||||
let restored = Session::load_from_path(&session_path).expect("cleared session should load");
|
||||
assert!(restored.messages.is_empty());
|
||||
|
||||
let backup_path = stdout
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix(" Backup "))
|
||||
.map(PathBuf::from)
|
||||
.expect("clear output should include backup path");
|
||||
let backup = Session::load_from_path(&backup_path).expect("backup session should load");
|
||||
assert_eq!(backup.messages.len(), 1);
|
||||
assert!(matches!(
|
||||
backup.messages[0].blocks.first(),
|
||||
Some(ContentBlock::Text { text }) if text == "ship the slash command harness"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_applies_cli_flags_end_to_end() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("status-command-flags");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--permission-mode",
|
||||
"read-only",
|
||||
"status",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Status"));
|
||||
assert!(stdout.contains("Model claude-sonnet-4-6"));
|
||||
assert!(stdout.contains("Permission mode read-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_config_command_loads_settings_files_end_to_end() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-config");
|
||||
let project_dir = temp_dir.join("project");
|
||||
let config_home = temp_dir.join("home").join(".claw");
|
||||
fs::create_dir_all(project_dir.join(".claw")).expect("project config dir should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
|
||||
let session_path = project_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.with_persistence_path(&session_path)
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
fs::write(config_home.join("settings.json"), r#"{"model":"haiku"}"#)
|
||||
.expect("user config should write");
|
||||
fs::write(
|
||||
project_dir.join(".claw").join("settings.local.json"),
|
||||
r#"{"model":"opus"}"#,
|
||||
)
|
||||
.expect("local config should write");
|
||||
|
||||
// when
|
||||
let output = run_claw_with_env(
|
||||
&project_dir,
|
||||
&[
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/config",
|
||||
"model",
|
||||
],
|
||||
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Config"));
|
||||
assert!(stdout.contains("Loaded files 2"));
|
||||
assert!(stdout.contains(
|
||||
config_home
|
||||
.join("settings.json")
|
||||
.to_str()
|
||||
.expect("utf8 path")
|
||||
));
|
||||
assert!(stdout.contains(
|
||||
project_dir
|
||||
.join(".claw")
|
||||
.join("settings.local.json")
|
||||
.to_str()
|
||||
.expect("utf8 path")
|
||||
));
|
||||
assert!(stdout.contains("Merged section: model"));
|
||||
assert!(stdout.contains("opus"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_latest_restores_the_most_recent_managed_session() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-latest");
|
||||
let project_dir = temp_dir.join("project");
|
||||
let sessions_dir = project_dir.join(".claw").join("sessions");
|
||||
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
||||
|
||||
let older_path = sessions_dir.join("session-older.jsonl");
|
||||
let newer_path = sessions_dir.join("session-newer.jsonl");
|
||||
|
||||
let mut older = Session::new().with_persistence_path(&older_path);
|
||||
older
|
||||
.push_user_text("older session")
|
||||
.expect("older session write should succeed");
|
||||
older
|
||||
.save_to_path(&older_path)
|
||||
.expect("older session should persist");
|
||||
|
||||
let mut newer = Session::new().with_persistence_path(&newer_path);
|
||||
newer
|
||||
.push_user_text("newer session")
|
||||
.expect("newer session write should succeed");
|
||||
newer
|
||||
.push_user_text("resume me")
|
||||
.expect("newer session write should succeed");
|
||||
newer
|
||||
.save_to_path(&newer_path)
|
||||
.expect("newer session should persist");
|
||||
|
||||
// when
|
||||
let output = run_claw(&project_dir, &["--resume", "latest", "/status"]);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
assert!(stdout.contains("Status"));
|
||||
assert!(stdout.contains("Messages 2"));
|
||||
assert!(stdout.contains(newer_path.to_str().expect("utf8 path")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_status_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-status-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text("resume status json fixture")
|
||||
.expect("session write should succeed");
|
||||
session
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/status",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("resume status output should be json");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
// model is null in resume mode (not known without --model flag)
|
||||
assert!(parsed["model"].is_null());
|
||||
assert_eq!(parsed["permission_mode"], "danger-full-access");
|
||||
assert_eq!(parsed["usage"]["messages"], 1);
|
||||
assert!(parsed["usage"]["turns"].is_number());
|
||||
assert!(parsed["workspace"]["cwd"].as_str().is_some());
|
||||
assert_eq!(
|
||||
parsed["workspace"]["session"],
|
||||
session_path.to_str().expect("utf8 path")
|
||||
);
|
||||
assert!(parsed["workspace"]["changed_files"].is_number());
|
||||
assert_eq!(parsed["workspace"]["loaded_config_files"].as_u64(), Some(0));
|
||||
assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_status_surfaces_persisted_model() {
|
||||
// given — create a session with model already set
|
||||
let temp_dir = unique_temp_dir("resume-status-model");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
let mut session = Session::new();
|
||||
session.model = Some("claude-sonnet-4-6".to_string());
|
||||
session
|
||||
.push_user_text("model persistence fixture")
|
||||
.expect("write ok");
|
||||
session.save_to_path(&session_path).expect("persist ok");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/status",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "status");
|
||||
assert_eq!(
|
||||
parsed["model"], "claude-sonnet-4-6",
|
||||
"model should round-trip through session metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
||||
// given
|
||||
let temp_dir = unique_temp_dir("resume-sandbox-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
// when
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/sandbox",
|
||||
],
|
||||
);
|
||||
|
||||
// then
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
|
||||
let parsed: Value =
|
||||
serde_json::from_str(stdout.trim()).expect("resume sandbox output should be json");
|
||||
assert_eq!(parsed["kind"], "sandbox");
|
||||
assert!(parsed["enabled"].is_boolean());
|
||||
assert!(parsed["active"].is_boolean());
|
||||
assert!(parsed["supported"].is_boolean());
|
||||
assert!(parsed["filesystem_mode"].as_str().is_some());
|
||||
assert!(parsed["allowed_mounts"].is_array());
|
||||
assert!(parsed["markers"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_version_command_emits_structured_json() {
|
||||
let temp_dir = unique_temp_dir("resume-version-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("session should persist");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/version",
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert!(parsed["version"].as_str().is_some());
|
||||
assert!(parsed["git_sha"].as_str().is_some());
|
||||
assert!(parsed["target"].as_str().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_export_command_emits_structured_json() {
|
||||
let temp_dir = unique_temp_dir("resume-export-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text("export json fixture")
|
||||
.expect("write ok");
|
||||
session.save_to_path(&session_path).expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/export",
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "export");
|
||||
assert!(parsed["file"].as_str().is_some());
|
||||
assert_eq!(parsed["message_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_help_command_emits_structured_json() {
|
||||
let temp_dir = unique_temp_dir("resume-help-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/help",
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert!(parsed["text"].as_str().is_some());
|
||||
let text = parsed["text"].as_str().unwrap();
|
||||
assert!(text.contains("/status"), "help text should list /status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_no_command_emits_restored_json() {
|
||||
let temp_dir = unique_temp_dir("resume-no-cmd-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.push_user_text("restored json fixture")
|
||||
.expect("write ok");
|
||||
session.save_to_path(&session_path).expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(output.stdout).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
|
||||
assert_eq!(parsed["kind"], "restored");
|
||||
assert!(parsed["session_id"].as_str().is_some());
|
||||
assert!(parsed["path"].as_str().is_some());
|
||||
assert_eq!(parsed["message_count"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumed_stub_command_emits_not_implemented_json() {
|
||||
let temp_dir = unique_temp_dir("resume-stub-json");
|
||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||
let session_path = temp_dir.join("session.jsonl");
|
||||
Session::new()
|
||||
.save_to_path(&session_path)
|
||||
.expect("persist ok");
|
||||
|
||||
let output = run_claw(
|
||||
&temp_dir,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"--resume",
|
||||
session_path.to_str().expect("utf8 path"),
|
||||
"/allowed-tools",
|
||||
],
|
||||
);
|
||||
|
||||
// Stub commands exit with code 2
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
assert_eq!(parsed["type"], "error");
|
||||
assert!(
|
||||
parsed["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("not yet implemented"),
|
||||
"error should say not yet implemented: {:?}",
|
||||
parsed["error"]
|
||||
);
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
||||
run_claw_with_env(current_dir, args, &[])
|
||||
}
|
||||
|
||||
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||
command.current_dir(current_dir).args(args);
|
||||
for (key, value) in envs {
|
||||
command.env(key, value);
|
||||
}
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("clock should be after epoch")
|
||||
.as_millis();
|
||||
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!(
|
||||
"claw-{label}-{}-{millis}-{counter}",
|
||||
std::process::id()
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user