Merge remote-tracking branch 'origin/rcc/cli' into dev/rust

This commit is contained in:
Yeachan-Heo
2026-03-31 21:20:26 +00:00
2 changed files with 363 additions and 75 deletions

View File

@@ -89,8 +89,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
}, },
SlashCommandSpec { SlashCommandSpec {
name: "config", name: "config",
summary: "Inspect discovered Claude config files", summary: "Inspect Claude config files or merged sections",
argument_hint: None, argument_hint: Some("[env|hooks|model]"),
resume_supported: true, resume_supported: true,
}, },
SlashCommandSpec { SlashCommandSpec {
@@ -117,7 +117,7 @@ pub enum SlashCommand {
Clear { confirm: bool }, Clear { confirm: bool },
Cost, Cost,
Resume { session_path: Option<String> }, Resume { session_path: Option<String> },
Config, Config { section: Option<String> },
Memory, Memory,
Init, Init,
Unknown(String), Unknown(String),
@@ -150,7 +150,9 @@ impl SlashCommand {
"resume" => Self::Resume { "resume" => Self::Resume {
session_path: parts.next().map(ToOwned::to_owned), session_path: parts.next().map(ToOwned::to_owned),
}, },
"config" => Self::Config, "config" => Self::Config {
section: parts.next().map(ToOwned::to_owned),
},
"memory" => Self::Memory, "memory" => Self::Memory,
"init" => Self::Init, "init" => Self::Init,
other => Self::Unknown(other.to_string()), other => Self::Unknown(other.to_string()),
@@ -174,8 +176,8 @@ pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
#[must_use] #[must_use]
pub fn render_slash_command_help() -> String { pub fn render_slash_command_help() -> String {
let mut lines = vec![ let mut lines = vec![
"Available commands:".to_string(), "Slash commands".to_string(),
" (resume-safe commands are marked with [resume])".to_string(), " [resume] means the command also works with --resume SESSION.json".to_string(),
]; ];
for spec in slash_command_specs() { for spec in slash_command_specs() {
let name = match spec.argument_hint { let name = match spec.argument_hint {
@@ -230,7 +232,7 @@ pub fn handle_slash_command(
| SlashCommand::Clear { .. } | SlashCommand::Clear { .. }
| SlashCommand::Cost | SlashCommand::Cost
| SlashCommand::Resume { .. } | SlashCommand::Resume { .. }
| SlashCommand::Config | SlashCommand::Config { .. }
| SlashCommand::Memory | SlashCommand::Memory
| SlashCommand::Init | SlashCommand::Init
| SlashCommand::Unknown(_) => None, | SlashCommand::Unknown(_) => None,
@@ -280,7 +282,16 @@ mod tests {
session_path: Some("session.json".to_string()), session_path: Some("session.json".to_string()),
}) })
); );
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(
SlashCommand::parse("/config"),
Some(SlashCommand::Config { section: None })
);
assert_eq!(
SlashCommand::parse("/config env"),
Some(SlashCommand::Config {
section: Some("env".to_string())
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
} }
@@ -288,7 +299,7 @@ mod tests {
#[test] #[test]
fn renders_help_from_shared_specs() { fn renders_help_from_shared_specs() {
let help = render_slash_command_help(); let help = render_slash_command_help();
assert!(help.contains("resume-safe commands")); assert!(help.contains("works with --resume SESSION.json"));
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/compact")); assert!(help.contains("/compact"));
@@ -297,7 +308,7 @@ mod tests {
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert_eq!(slash_command_specs().len(), 11); assert_eq!(slash_command_specs().len(), 11);
@@ -340,7 +351,7 @@ mod tests {
let result = handle_slash_command("/help", &session, CompactionConfig::default()) let result = handle_slash_command("/help", &session, CompactionConfig::default())
.expect("help command should be handled"); .expect("help command should be handled");
assert_eq!(result.session, session); assert_eq!(result.session, session);
assert!(result.message.contains("Available commands:")); assert!(result.message.contains("Slash commands"));
} }
#[test] #[test]
@@ -370,5 +381,8 @@ mod tests {
) )
.is_none()); .is_none());
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
);
} }
} }

View File

@@ -12,9 +12,7 @@ use api::{
ToolResultContentBlock, ToolResultContentBlock,
}; };
use commands::{ use commands::{render_slash_command_help, resume_supported_slash_commands, SlashCommand};
handle_slash_command, render_slash_command_help, resume_supported_slash_commands, SlashCommand,
};
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use render::{Spinner, TerminalRenderer}; use render::{Spinner, TerminalRenderer};
use runtime::{ use runtime::{
@@ -258,6 +256,8 @@ struct StatusContext {
loaded_config_files: usize, loaded_config_files: usize,
discovered_config_files: usize, discovered_config_files: usize,
memory_file_count: usize, memory_file_count: usize,
project_root: Option<PathBuf>,
git_branch: Option<String>,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -291,6 +291,122 @@ fn format_model_switch_report(previous: &str, next: &str, message_count: usize)
) )
} }
fn format_permissions_report(mode: &str) -> String {
format!(
"Permissions
Current mode {mode}
Available modes
read-only Allow read/search tools only
workspace-write Allow editing within the workspace
danger-full-access Allow unrestricted tool access"
)
}
fn format_permissions_switch_report(previous: &str, next: &str) -> String {
format!(
"Permissions updated
Previous {previous}
Current {next}"
)
}
fn format_cost_report(usage: TokenUsage) -> String {
format!(
"Cost
Input tokens {}
Output tokens {}
Cache create {}
Cache read {}
Total tokens {}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)
}
fn format_resume_report(session_path: &str, message_count: usize, turns: u32) -> String {
format!(
"Session resumed
Session file {session_path}
Messages {message_count}
Turns {turns}"
)
}
fn format_init_report(path: &Path, created: bool) -> String {
if created {
format!(
"Init
CLAUDE.md {}
Result created
Next step Review and tailor the generated guidance",
path.display()
)
} else {
format!(
"Init
CLAUDE.md {}
Result skipped (already exists)
Next step Edit the existing file intentionally if workflows changed",
path.display()
)
}
}
fn format_compact_report(removed: usize, resulting_messages: usize, skipped: bool) -> String {
if skipped {
format!(
"Compact
Result skipped
Reason session below compaction threshold
Messages kept {resulting_messages}"
)
} else {
format!(
"Compact
Result compacted
Messages removed {removed}
Messages kept {resulting_messages}"
)
}
}
fn parse_git_status_metadata(status: Option<&str>) -> (Option<PathBuf>, Option<String>) {
let Some(status) = status else {
return (None, None);
};
let branch = status.lines().next().and_then(|line| {
line.strip_prefix("## ")
.map(|line| {
line.split(['.', ' '])
.next()
.unwrap_or_default()
.to_string()
})
.filter(|value| !value.is_empty())
});
let project_root = find_git_root().ok();
(project_root, branch)
}
fn find_git_root() -> Result<PathBuf, Box<dyn std::error::Error>> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(env::current_dir()?)
.output()?;
if !output.status.success() {
return Err("not a git repository".into());
}
let path = String::from_utf8(output.stdout)?.trim().to_string();
if path.is_empty() {
return Err("empty git root".into());
}
Ok(PathBuf::from(path))
}
fn run_resume_command( fn run_resume_command(
session_path: &Path, session_path: &Path,
session: &Session, session: &Session,
@@ -302,23 +418,20 @@ fn run_resume_command(
message: Some(render_repl_help()), message: Some(render_repl_help()),
}), }),
SlashCommand::Compact => { SlashCommand::Compact => {
let Some(result) = handle_slash_command( let result = runtime::compact_session(
"/compact",
session, session,
CompactionConfig { CompactionConfig {
max_estimated_tokens: 0, max_estimated_tokens: 0,
..CompactionConfig::default() ..CompactionConfig::default()
}, },
) else { );
return Ok(ResumeCommandOutcome { let removed = result.removed_message_count;
session: session.clone(), let kept = result.compacted_session.messages.len();
message: None, let skipped = removed == 0;
}); result.compacted_session.save_to_path(session_path)?;
};
result.session.save_to_path(session_path)?;
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: result.session, session: result.compacted_session,
message: Some(result.message), message: Some(format_compact_report(removed, kept, skipped)),
}) })
} }
SlashCommand::Clear { confirm } => { SlashCommand::Clear { confirm } => {
@@ -363,19 +476,12 @@ fn run_resume_command(
let usage = UsageTracker::from_session(session).cumulative_usage(); let usage = UsageTracker::from_session(session).cumulative_usage();
Ok(ResumeCommandOutcome { Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(format!( message: Some(format_cost_report(usage)),
"cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
usage.input_tokens,
usage.output_tokens,
usage.cache_creation_input_tokens,
usage.cache_read_input_tokens,
usage.total_tokens(),
)),
}) })
} }
SlashCommand::Config => Ok(ResumeCommandOutcome { SlashCommand::Config { section } => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
message: Some(render_config_report()?), message: Some(render_config_report(section.as_deref())?),
}), }),
SlashCommand::Memory => Ok(ResumeCommandOutcome { SlashCommand::Memory => Ok(ResumeCommandOutcome {
session: session.clone(), session: session.clone(),
@@ -481,7 +587,7 @@ impl LiveCli {
SlashCommand::Clear { confirm } => self.clear_session(confirm)?, SlashCommand::Clear { confirm } => self.clear_session(confirm)?,
SlashCommand::Cost => self.print_cost(), SlashCommand::Cost => self.print_cost(),
SlashCommand::Resume { session_path } => self.resume_session(session_path)?, SlashCommand::Resume { session_path } => self.resume_session(session_path)?,
SlashCommand::Config => Self::print_config()?, SlashCommand::Config { section } => Self::print_config(section.as_deref())?,
SlashCommand::Memory => Self::print_memory()?, SlashCommand::Memory => Self::print_memory()?,
SlashCommand::Init => Self::run_init()?, SlashCommand::Init => Self::run_init()?,
SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
@@ -548,7 +654,7 @@ impl LiveCli {
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> { fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let Some(mode) = mode else { let Some(mode) = mode else {
println!("Current permission mode: {}", permission_mode_label()); println!("{}", format_permissions_report(permission_mode_label()));
return Ok(()); return Ok(());
}; };
@@ -559,10 +665,11 @@ impl LiveCli {
})?; })?;
if normalized == permission_mode_label() { if normalized == permission_mode_label() {
println!("Permission mode already set to {normalized}."); println!("{}", format_permissions_report(normalized));
return Ok(()); return Ok(());
} }
let previous = permission_mode_label().to_string();
let session = self.runtime.session().clone(); let session = self.runtime.session().clone();
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime_with_permission_mode(
session, session,
@@ -571,7 +678,10 @@ impl LiveCli {
true, true,
normalized, normalized,
)?; )?;
println!("Switched permission mode to {normalized}."); println!(
"{}",
format_permissions_switch_report(&previous, normalized)
);
Ok(()) Ok(())
} }
@@ -590,20 +700,20 @@ impl LiveCli {
true, true,
permission_mode_label(), permission_mode_label(),
)?; )?;
println!("Cleared local session history."); println!(
"Session cleared
Mode fresh session
Preserved model {}
Permission mode {}",
self.model,
permission_mode_label()
);
Ok(()) Ok(())
} }
fn print_cost(&self) { fn print_cost(&self) {
let cumulative = self.runtime.usage().cumulative_usage(); let cumulative = self.runtime.usage().cumulative_usage();
println!( println!("{}", format_cost_report(cumulative));
"cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}",
cumulative.input_tokens,
cumulative.output_tokens,
cumulative.cache_creation_input_tokens,
cumulative.cache_read_input_tokens,
cumulative.total_tokens(),
);
} }
fn resume_session( fn resume_session(
@@ -624,12 +734,15 @@ impl LiveCli {
true, true,
permission_mode_label(), permission_mode_label(),
)?; )?;
println!("Resumed session from {session_path} ({message_count} messages)."); println!(
"{}",
format_resume_report(&session_path, message_count, self.runtime.usage().turns())
);
Ok(()) Ok(())
} }
fn print_config() -> Result<(), Box<dyn std::error::Error>> { fn print_config(section: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
println!("{}", render_config_report()?); println!("{}", render_config_report(section)?);
Ok(()) Ok(())
} }
@@ -646,6 +759,8 @@ impl LiveCli {
fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> { fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig::default()); let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count; let removed = result.removed_message_count;
let kept = result.compacted_session.messages.len();
let skipped = removed == 0;
self.runtime = build_runtime_with_permission_mode( self.runtime = build_runtime_with_permission_mode(
result.compacted_session, result.compacted_session,
self.model.clone(), self.model.clone(),
@@ -653,16 +768,22 @@ impl LiveCli {
true, true,
permission_mode_label(), permission_mode_label(),
)?; )?;
println!("Compacted {removed} messages."); println!("{}", format_compact_report(removed, kept, skipped));
Ok(()) Ok(())
} }
} }
fn render_repl_help() -> String { fn render_repl_help() -> String {
format!( [
"{} "REPL".to_string(),
/exit Quit the REPL", " /exit Quit the REPL".to_string(),
render_slash_command_help() " /quit Quit the REPL".to_string(),
String::new(),
render_slash_command_help(),
]
.join(
"
",
) )
} }
@@ -673,13 +794,17 @@ fn status_context(
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let discovered_config_files = loader.discover().len(); let discovered_config_files = loader.discover().len();
let runtime_config = loader.load()?; let runtime_config = loader.load()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?; let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?;
let (project_root, git_branch) =
parse_git_status_metadata(project_context.git_status.as_deref());
Ok(StatusContext { Ok(StatusContext {
cwd, cwd,
session_path: session_path.map(Path::to_path_buf), session_path: session_path.map(Path::to_path_buf),
loaded_config_files: runtime_config.loaded_entries().len(), loaded_config_files: runtime_config.loaded_entries().len(),
discovered_config_files, discovered_config_files,
memory_file_count: project_context.instruction_files.len(), memory_file_count: project_context.instruction_files.len(),
project_root,
git_branch,
}) })
} }
@@ -713,10 +838,17 @@ fn format_status_report(
format!( format!(
"Workspace "Workspace
Cwd {} Cwd {}
Project root {}
Git branch {}
Session {} Session {}
Config files loaded {}/{} Config files loaded {}/{}
Memory files {}", Memory files {}",
context.cwd.display(), context.cwd.display(),
context
.project_root
.as_ref()
.map_or_else(|| "unknown".to_string(), |path| path.display().to_string()),
context.git_branch.as_deref().unwrap_or("unknown"),
context.session_path.as_ref().map_or_else( context.session_path.as_ref().map_or_else(
|| "live-repl".to_string(), || "live-repl".to_string(),
|path| path.display().to_string() |path| path.display().to_string()
@@ -733,7 +865,7 @@ fn format_status_report(
) )
} }
fn render_config_report() -> Result<String, Box<dyn std::error::Error>> { fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd); let loader = ConfigLoader::default_for(&cwd);
let discovered = loader.discover(); let discovered = loader.discover();
@@ -771,6 +903,36 @@ fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
entry.path.display() entry.path.display()
)); ));
} }
if let Some(section) = section {
lines.push(format!("Merged section: {section}"));
let value = match section {
"env" => runtime_config.get("env"),
"hooks" => runtime_config.get("hooks"),
"model" => runtime_config.get("model"),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, or model."
));
return Ok(lines.join(
"
",
));
}
};
lines.push(format!(
" {}",
match value {
Some(value) => value.render(),
None => "<unset>".to_string(),
}
));
return Ok(lines.join(
"
",
));
}
lines.push("Merged JSON".to_string()); lines.push("Merged JSON".to_string());
lines.push(format!(" {}", runtime_config.as_json().render())); lines.push(format!(" {}", runtime_config.as_json().render()));
Ok(lines.join( Ok(lines.join(
@@ -780,27 +942,33 @@ fn render_config_report() -> Result<String, Box<dyn std::error::Error>> {
} }
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> { fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
let project_context = ProjectContext::discover(env::current_dir()?, DEFAULT_DATE)?; let cwd = env::current_dir()?;
let project_context = ProjectContext::discover(&cwd, DEFAULT_DATE)?;
let mut lines = vec![format!( let mut lines = vec![format!(
"memory: files={}", "Memory
Working directory {}
Instruction files {}",
cwd.display(),
project_context.instruction_files.len() project_context.instruction_files.len()
)]; )];
if project_context.instruction_files.is_empty() { if project_context.instruction_files.is_empty() {
lines.push("Discovered files".to_string());
lines.push( lines.push(
" No CLAUDE instruction files discovered in the current directory ancestry." " No CLAUDE instruction files discovered in the current directory ancestry."
.to_string(), .to_string(),
); );
} else { } else {
for file in project_context.instruction_files { lines.push("Discovered files".to_string());
for (index, file) in project_context.instruction_files.iter().enumerate() {
let preview = file.content.lines().next().unwrap_or("").trim(); let preview = file.content.lines().next().unwrap_or("").trim();
let preview = if preview.is_empty() { let preview = if preview.is_empty() {
"<empty>" "<empty>"
} else { } else {
preview preview
}; };
lines.push(format!(" {}. {}", index + 1, file.path.display(),));
lines.push(format!( lines.push(format!(
" {} ({}) {}", " lines={} preview={}",
file.path.display(),
file.content.lines().count(), file.content.lines().count(),
preview preview
)); ));
@@ -816,15 +984,12 @@ fn init_claude_md() -> Result<String, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let claude_md = cwd.join("CLAUDE.md"); let claude_md = cwd.join("CLAUDE.md");
if claude_md.exists() { if claude_md.exists() {
return Ok(format!( return Ok(format_init_report(&claude_md, false));
"init: skipped because {} already exists",
claude_md.display()
));
} }
let content = render_init_claude_md(&cwd); let content = render_init_claude_md(&cwd);
fs::write(&claude_md, content)?; fs::write(&claude_md, content)?;
Ok(format!("init: created {}", claude_md.display())) Ok(format_init_report(&claude_md, true))
} }
fn render_init_claude_md(cwd: &Path) -> String { fn render_init_claude_md(cwd: &Path) -> String {
@@ -1234,10 +1399,12 @@ fn print_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
format_model_report, format_model_switch_report, format_status_report, format_compact_report, format_cost_report, format_init_report, format_model_report,
normalize_permission_mode, parse_args, render_init_claude_md, render_repl_help, format_model_switch_report, format_permissions_report, format_permissions_switch_report,
resume_supported_slash_commands, status_context, CliAction, SlashCommand, StatusUsage, format_resume_report, format_status_report, normalize_permission_mode, parse_args,
DEFAULT_MODEL, parse_git_status_metadata, render_config_report, render_init_claude_md,
render_memory_report, render_repl_help, resume_supported_slash_commands, status_context,
CliAction, SlashCommand, StatusUsage, DEFAULT_MODEL,
}; };
use runtime::{ContentBlock, ConversationMessage, MessageRole}; use runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -1324,9 +1491,17 @@ mod tests {
); );
} }
#[test]
fn shared_help_uses_resume_annotation_copy() {
let help = commands::render_slash_command_help();
assert!(help.contains("Slash commands"));
assert!(help.contains("works with --resume SESSION.json"));
}
#[test] #[test]
fn repl_help_includes_shared_commands_and_exit() { fn repl_help_includes_shared_commands_and_exit() {
let help = render_repl_help(); let help = render_repl_help();
assert!(help.contains("REPL"));
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/model [model]")); assert!(help.contains("/model [model]"));
@@ -1334,7 +1509,7 @@ mod tests {
assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/clear [--confirm]"));
assert!(help.contains("/cost")); assert!(help.contains("/cost"));
assert!(help.contains("/resume <session-path>")); assert!(help.contains("/resume <session-path>"));
assert!(help.contains("/config")); assert!(help.contains("/config [env|hooks|model]"));
assert!(help.contains("/memory")); assert!(help.contains("/memory"));
assert!(help.contains("/init")); assert!(help.contains("/init"));
assert!(help.contains("/exit")); assert!(help.contains("/exit"));
@@ -1352,6 +1527,67 @@ mod tests {
); );
} }
#[test]
fn resume_report_uses_sectioned_layout() {
let report = format_resume_report("session.json", 14, 6);
assert!(report.contains("Session resumed"));
assert!(report.contains("Session file session.json"));
assert!(report.contains("Messages 14"));
assert!(report.contains("Turns 6"));
}
#[test]
fn compact_report_uses_structured_output() {
let compacted = format_compact_report(8, 5, false);
assert!(compacted.contains("Compact"));
assert!(compacted.contains("Result compacted"));
assert!(compacted.contains("Messages removed 8"));
let skipped = format_compact_report(0, 3, true);
assert!(skipped.contains("Result skipped"));
}
#[test]
fn cost_report_uses_sectioned_layout() {
let report = format_cost_report(runtime::TokenUsage {
input_tokens: 20,
output_tokens: 8,
cache_creation_input_tokens: 3,
cache_read_input_tokens: 1,
});
assert!(report.contains("Cost"));
assert!(report.contains("Input tokens 20"));
assert!(report.contains("Output tokens 8"));
assert!(report.contains("Cache create 3"));
assert!(report.contains("Cache read 1"));
assert!(report.contains("Total tokens 32"));
}
#[test]
fn permissions_report_uses_sectioned_layout() {
let report = format_permissions_report("workspace-write");
assert!(report.contains("Permissions"));
assert!(report.contains("Current mode workspace-write"));
assert!(report.contains("Available modes"));
assert!(report.contains("danger-full-access"));
}
#[test]
fn permissions_switch_report_is_structured() {
let report = format_permissions_switch_report("read-only", "workspace-write");
assert!(report.contains("Permissions updated"));
assert!(report.contains("Previous read-only"));
assert!(report.contains("Current workspace-write"));
}
#[test]
fn init_report_uses_structured_output() {
let created = format_init_report(Path::new("/tmp/CLAUDE.md"), true);
assert!(created.contains("Init"));
assert!(created.contains("Result created"));
let skipped = format_init_report(Path::new("/tmp/CLAUDE.md"), false);
assert!(skipped.contains("skipped (already exists)"));
}
#[test] #[test]
fn model_report_uses_sectioned_layout() { fn model_report_uses_sectioned_layout() {
let report = format_model_report("claude-sonnet", 12, 4); let report = format_model_report("claude-sonnet", 12, 4);
@@ -1398,6 +1634,8 @@ mod tests {
loaded_config_files: 2, loaded_config_files: 2,
discovered_config_files: 3, discovered_config_files: 3,
memory_file_count: 4, memory_file_count: 4,
project_root: Some(PathBuf::from("/tmp")),
git_branch: Some("main".to_string()),
}, },
); );
assert!(status.contains("Status")); assert!(status.contains("Status"));
@@ -1407,19 +1645,46 @@ mod tests {
assert!(status.contains("Latest total 10")); assert!(status.contains("Latest total 10"));
assert!(status.contains("Cumulative total 31")); assert!(status.contains("Cumulative total 31"));
assert!(status.contains("Cwd /tmp/project")); assert!(status.contains("Cwd /tmp/project"));
assert!(status.contains("Project root /tmp"));
assert!(status.contains("Git branch main"));
assert!(status.contains("Session session.json")); assert!(status.contains("Session session.json"));
assert!(status.contains("Config files loaded 2/3")); assert!(status.contains("Config files loaded 2/3"));
assert!(status.contains("Memory files 4")); assert!(status.contains("Memory files 4"));
} }
#[test]
fn config_report_supports_section_views() {
let report = render_config_report(Some("env")).expect("config report should render");
assert!(report.contains("Merged section: env"));
}
#[test]
fn memory_report_uses_sectioned_layout() {
let report = render_memory_report().expect("memory report should render");
assert!(report.contains("Memory"));
assert!(report.contains("Working directory"));
assert!(report.contains("Instruction files"));
assert!(report.contains("Discovered files"));
}
#[test] #[test]
fn config_report_uses_sectioned_layout() { fn config_report_uses_sectioned_layout() {
let report = super::render_config_report().expect("config report should render"); let report = render_config_report(None).expect("config report should render");
assert!(report.contains("Config")); assert!(report.contains("Config"));
assert!(report.contains("Discovered files")); assert!(report.contains("Discovered files"));
assert!(report.contains("Merged JSON")); assert!(report.contains("Merged JSON"));
} }
#[test]
fn parses_git_status_metadata() {
let (root, branch) = parse_git_status_metadata(Some(
"## rcc/cli...origin/rcc/cli
M src/main.rs",
));
assert_eq!(branch.as_deref(), Some("rcc/cli"));
let _ = root;
}
#[test] #[test]
fn status_context_reads_real_workspace_metadata() { fn status_context_reads_real_workspace_metadata() {
let context = status_context(None).expect("status context should load"); let context = status_context(None).expect("status context should load");
@@ -1466,7 +1731,16 @@ mod tests {
SlashCommand::parse("/clear --confirm"), SlashCommand::parse("/clear --confirm"),
Some(SlashCommand::Clear { confirm: true }) Some(SlashCommand::Clear { confirm: true })
); );
assert_eq!(SlashCommand::parse("/config"), Some(SlashCommand::Config)); assert_eq!(
SlashCommand::parse("/config"),
Some(SlashCommand::Config { section: None })
);
assert_eq!(
SlashCommand::parse("/config env"),
Some(SlashCommand::Config {
section: Some("env".to_string())
})
);
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
} }