use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use crate::interpreter::{ExecResult, OutputData, OutputNode};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
pub struct KaishVfs;
#[derive(Parser, Debug)]
#[command(name = "kaish-vfs", about = "Inspect and manage the overlay VFS transaction")]
struct KaishVfsArgs {
#[command(flatten)]
global: GlobalFlags,
args: Vec<String>,
}
const NO_OVERLAY_MSG: &str =
"no overlay active; start the session with --overlay (REPL/MCP) \
or KernelConfig::with_overlay(true) (embedder API)";
#[async_trait]
impl Tool for KaishVfs {
fn name(&self) -> &str {
"kaish-vfs"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&KaishVfsArgs::command(),
"kaish-vfs",
"Inspect and manage the overlay VFS transaction (requires --overlay)",
[
("Show session mode and overlay status (works in any session)", "kaish-vfs status"),
("Show unified diff of all changes", "kaish-vfs diff"),
("Show diff for a specific path", "kaish-vfs diff src/main.rs"),
("Commit all changes to real files", "kaish-vfs commit"),
("Discard all virtual edits", "kaish-vfs reset"),
("Discard edits to one path", "kaish-vfs reset src/main.rs"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match KaishVfsArgs::try_parse_from(
std::iter::once("kaish-vfs".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("kaish-vfs: {e}")),
};
parsed.global.apply(ctx);
let subcmd = match args.get_string("", 0) {
Some(s) => s,
None => return ExecResult::failure(1, format!("kaish-vfs: missing subcommand (status, diff, commit, reset)\n{}", NO_OVERLAY_MSG)),
};
match subcmd.as_str() {
"status" => cmd_status(ctx).await,
"diff" => {
let path_args: Vec<String> = args.positional.iter().skip(1)
.filter_map(|v| match v {
crate::ast::Value::String(s) => Some(s.clone()),
_ => None,
})
.collect();
cmd_diff(&path_args, ctx).await
}
"commit" => cmd_commit(ctx).await,
"reset" => {
let extra: Vec<String> = args.positional.iter().skip(2)
.filter_map(|v| match v {
crate::ast::Value::String(s) => Some(s.clone()),
_ => None,
})
.collect();
if !extra.is_empty() {
return ExecResult::failure(1, format!(
"kaish-vfs reset: too many arguments ({}); \
reset takes at most one path — run reset multiple times \
or omit the path to reset everything",
extra.join(", ")
));
}
let path_arg = args.get_string("", 1);
cmd_reset(path_arg.as_deref(), ctx).await
}
other => ExecResult::failure(1, format!(
"kaish-vfs: unknown subcommand '{}' (try: status, diff, commit, reset)",
other
)),
}
}
}
fn direct_mode_status(ctx: &ExecContext) -> ExecResult {
let budget_str = match &ctx.vfs_budget {
Some(b) => format!(
"{} used / {} limit",
format_bytes(b.used()),
format_bytes(b.limit())
),
None => "unlimited".to_string(),
};
let headers = vec!["KEY".to_string(), "VALUE".to_string()];
let rows = vec![
OutputNode::new("mode").with_cells(vec!["direct".to_string()]),
OutputNode::new("budget").with_cells(vec![budget_str]),
];
ExecResult::with_output(OutputData::table(headers, rows))
}
#[cfg(all(feature = "localfs", feature = "overlay"))]
async fn cmd_status(ctx: &ExecContext) -> ExecResult {
use kaish_vfs::Filesystem as _; let handle = match &ctx.overlay_handle {
Some(h) => h.clone(),
None => return direct_mode_status(ctx),
};
let is_dirty = handle.fs.is_dirty().await;
let changes_result = handle.fs.changes().await;
let (added, modified, removed) = match &changes_result {
Ok(changes) => {
let added = changes.iter().filter(|c| c.kind == kaish_vfs::ChangeKind::Added).count();
let modified = changes.iter().filter(|c| c.kind == kaish_vfs::ChangeKind::Modified).count();
let removed = changes.iter().filter(|c| c.kind == kaish_vfs::ChangeKind::Removed).count();
(added, modified, removed)
}
Err(e) => {
return ExecResult::failure(1, format!("kaish-vfs status: {}", e));
}
};
let resident = handle.fs.resident_bytes();
let resident_str = match resident {
Some(b) => format_bytes(b),
None => "-".to_string(),
};
let budget_str = match &ctx.vfs_budget {
Some(b) => format!("{} used / {} limit", format_bytes(b.used()), format_bytes(b.limit())),
None => "unlimited".to_string(),
};
let headers = vec!["KEY".to_string(), "VALUE".to_string()];
let rows = vec![
OutputNode::new("mode").with_cells(vec!["transaction".to_string()]),
OutputNode::new("dirty").with_cells(vec![if is_dirty { "yes" } else { "no" }.to_string()]),
OutputNode::new("added").with_cells(vec![added.to_string()]),
OutputNode::new("modified").with_cells(vec![modified.to_string()]),
OutputNode::new("removed").with_cells(vec![removed.to_string()]),
OutputNode::new("overlay-mount").with_cells(vec![handle.mount_path.to_string_lossy().to_string()]),
OutputNode::new("overlay-resident").with_cells(vec![resident_str]),
OutputNode::new("budget").with_cells(vec![budget_str]),
];
ExecResult::with_output(OutputData::table(headers, rows))
}
#[cfg(not(all(feature = "localfs", feature = "overlay")))]
async fn cmd_status(ctx: &ExecContext) -> ExecResult {
direct_mode_status(ctx)
}
#[cfg(all(feature = "localfs", feature = "overlay"))]
async fn cmd_diff(path_args: &[String], ctx: &ExecContext) -> ExecResult {
let handle = match &ctx.overlay_handle {
Some(h) => h.clone(),
None => return ExecResult::failure(1, NO_OVERLAY_MSG),
};
let colorize = false;
let changes = match handle.fs.changes().await {
Ok(c) => c,
Err(e) => return ExecResult::failure(1, format!("kaish-vfs diff: {}", e)),
};
let changes: Vec<_> = if path_args.is_empty() {
changes
} else {
let filter_paths: Vec<std::path::PathBuf> = path_args.iter().map(|arg| {
if arg.starts_with('/') {
std::path::PathBuf::from(arg)
} else {
ctx.cwd.join(arg)
}
}).collect();
changes.into_iter().filter(|change| {
let vfs_path = handle.mount_path.join(&change.path);
filter_paths.iter().any(|filter_path| {
vfs_path == *filter_path || vfs_path.starts_with(filter_path)
})
}).collect()
};
if changes.is_empty() {
return ExecResult::success("no changes");
}
let mut text_output = String::new();
let mut json_entries: Vec<serde_json::Value> = Vec::with_capacity(changes.len());
for change in &changes {
let vfs_path = handle.mount_path.join(&change.path);
let display = vfs_path.to_string_lossy();
let path_str = display.to_string();
match change.kind {
kaish_vfs::ChangeKind::Added => {
let current_bytes = change.current.as_deref().unwrap_or(b"");
let base_bytes: &[u8] = b"";
if let Ok(current_str) = std::str::from_utf8(current_bytes) {
let diff = similar::TextDiff::from_lines("", current_str);
let plain = diff.unified_diff()
.context_radius(3)
.header("/dev/null", &format!("b/{}", display))
.to_string();
let block = if colorize { colorize_diff(&plain) } else { plain };
text_output.push_str(&block);
} else {
text_output.push_str(&format!("binary file added: {}\n", display));
}
json_entries.push(serde_json::json!({
"path": path_str,
"kind": "added",
"base_bytes": base_bytes.len(),
"current_bytes": current_bytes.len(),
}));
}
kaish_vfs::ChangeKind::Removed => {
let base_bytes = change.base.as_deref().unwrap_or(b"");
let current_bytes: &[u8] = b"";
if let Ok(base_str) = std::str::from_utf8(base_bytes) {
let diff = similar::TextDiff::from_lines(base_str, "");
let plain = diff.unified_diff()
.context_radius(3)
.header(&format!("a/{}", display), "/dev/null")
.to_string();
let block = if colorize { colorize_diff(&plain) } else { plain };
text_output.push_str(&block);
} else {
text_output.push_str(&format!("binary file removed: {}\n", display));
}
json_entries.push(serde_json::json!({
"path": path_str,
"kind": "removed",
"base_bytes": base_bytes.len(),
"current_bytes": current_bytes.len(),
}));
}
kaish_vfs::ChangeKind::Modified => {
let base_bytes = change.base.as_deref().unwrap_or(b"");
let current_bytes = change.current.as_deref().unwrap_or(b"");
let base_utf8 = std::str::from_utf8(base_bytes);
let current_utf8 = std::str::from_utf8(current_bytes);
match (base_utf8, current_utf8) {
(Ok(base_str), Ok(current_str)) => {
let diff = similar::TextDiff::from_lines(base_str, current_str);
let plain = diff.unified_diff()
.context_radius(3)
.header(&format!("a/{}", display), &format!("b/{}", display))
.to_string();
let block = if colorize { colorize_diff(&plain) } else { plain };
text_output.push_str(&block);
}
_ => {
text_output.push_str(&format!("binary file changed: {}\n", display));
}
}
json_entries.push(serde_json::json!({
"path": path_str,
"kind": "modified",
"base_bytes": base_bytes.len(),
"current_bytes": current_bytes.len(),
}));
}
}
}
let output = OutputData::text(text_output.clone())
.with_rich_json(serde_json::Value::Array(json_entries));
let mut result = ExecResult::from_output(1, text_output, String::new());
result.set_output(Some(output));
result
}
#[cfg(not(all(feature = "localfs", feature = "overlay")))]
async fn cmd_diff(_path_args: &[String], ctx: &ExecContext) -> ExecResult {
let _ = ctx;
ExecResult::failure(1, NO_OVERLAY_MSG)
}
#[cfg(all(feature = "localfs", feature = "overlay"))]
async fn cmd_commit(ctx: &ExecContext) -> ExecResult {
use crate::vfs::LocalFs;
let handle = match &ctx.overlay_handle {
Some(h) => h.clone(),
None => return ExecResult::failure(1, NO_OVERLAY_MSG),
};
let target = LocalFs::new(handle.commit_root.clone());
let changes = match handle.fs.changes().await {
Ok(c) => c,
Err(e) => return ExecResult::failure(1, format!("kaish-vfs commit: pre-flight: {}", e)),
};
if changes.is_empty() {
return ExecResult::success("nothing to commit (overlay is clean)");
}
if let Err(e) = handle.fs.commit_into(&target).await {
return ExecResult::failure(1, format!(
"kaish-vfs commit: {}\n\
\n\
Recovery:\n\
- Any paths listed above as already committed are now in the real tree.\n\
- Run `kaish-vfs reset <path>` for each committed path to discard their\n\
overlay entries (they are already on disk — resetting is safe).\n\
- Run `kaish-vfs status` to see what remains dirty.\n\
- Resolve any conflicts in the real tree, then retry `kaish-vfs commit`.",
e
));
}
if let Err(e) = handle.fs.reset_all().await {
return ExecResult::failure(1, format!(
"kaish-vfs commit: committed successfully but reset_all failed: {}; \
overlay may still show dirty state but real files were written.",
e
));
}
let (added, modified, removed) = changes.iter().fold((0usize, 0usize, 0usize), |(a, m, r), c| {
match c.kind {
kaish_vfs::ChangeKind::Added => (a + 1, m, r),
kaish_vfs::ChangeKind::Modified => (a, m + 1, r),
kaish_vfs::ChangeKind::Removed => (a, m, r + 1),
}
});
let msg = format!("committed {} added, {} modified, {} removed", added, modified, removed);
ExecResult::success(msg)
}
#[cfg(not(all(feature = "localfs", feature = "overlay")))]
async fn cmd_commit(ctx: &ExecContext) -> ExecResult {
let _ = ctx;
ExecResult::failure(1, "kaish-vfs commit: not available in minimal builds (no localfs feature)")
}
#[cfg(all(feature = "localfs", feature = "overlay"))]
async fn cmd_reset(path_arg: Option<&str>, ctx: &ExecContext) -> ExecResult {
let handle = match &ctx.overlay_handle {
Some(h) => h.clone(),
None => return ExecResult::failure(1, NO_OVERLAY_MSG),
};
if let Some(path_str) = path_arg {
let abs_path = if path_str.starts_with('/') {
std::path::PathBuf::from(path_str)
} else {
ctx.cwd.join(path_str)
};
let rel = match abs_path.strip_prefix(&handle.mount_path) {
Ok(r) => r.to_path_buf(),
Err(_) => {
return ExecResult::failure(1, format!(
"kaish-vfs reset: {} is outside the overlay mount ({})",
path_str, handle.mount_path.display()
));
}
};
if let Err(e) = handle.fs.reset(&rel).await {
return ExecResult::failure(1, format!("kaish-vfs reset: {}", e));
}
ExecResult::success(format!("reset {}", path_str))
} else {
let changes = handle.fs.changes().await.ok();
let count = changes.as_ref().map(|c| c.len()).unwrap_or(0);
if let Err(e) = handle.fs.reset_all().await {
return ExecResult::failure(1, format!("kaish-vfs reset: {}", e));
}
ExecResult::success(format!("discarded {} virtual edit(s)", count))
}
}
#[cfg(not(all(feature = "localfs", feature = "overlay")))]
async fn cmd_reset(_path_arg: Option<&str>, ctx: &ExecContext) -> ExecResult {
let _ = ctx;
ExecResult::failure(1, NO_OVERLAY_MSG)
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
if bytes == 0 {
return "0 B".to_string();
}
let mut size = bytes as f64;
let mut idx = 0;
while size >= 1024.0 && idx < UNITS.len() - 1 {
size /= 1024.0;
idx += 1;
}
if idx == 0 {
format!("{} {}", bytes, UNITS[idx])
} else if size >= 10.0 {
format!("{:.0} {}", size, UNITS[idx])
} else {
format!("{:.1} {}", size, UNITS[idx])
}
}
#[cfg(all(feature = "localfs", feature = "overlay"))]
fn colorize_diff(plain: &str) -> String {
let mut output = String::with_capacity(plain.len() + 256);
for line in plain.lines() {
if line.starts_with("---") || line.starts_with("+++") {
output.push_str("\x1b[1m");
output.push_str(line);
output.push_str("\x1b[0m");
} else if line.starts_with("@@") {
output.push_str("\x1b[36m");
output.push_str(line);
output.push_str("\x1b[0m");
} else if line.starts_with('-') {
output.push_str("\x1b[31m");
output.push_str(line);
output.push_str("\x1b[0m");
} else if line.starts_with('+') {
output.push_str("\x1b[32m");
output.push_str(line);
output.push_str("\x1b[0m");
} else {
output.push_str(line);
}
output.push('\n');
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
fn make_ctx_no_overlay() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}
fn args_with_subcmd(subcmd: &str) -> ToolArgs {
let mut args = ToolArgs::new();
args.positional.push(Value::String(subcmd.to_string()));
args
}
#[tokio::test]
async fn test_status_without_overlay_reports_direct_mode() {
let mut ctx = make_ctx_no_overlay();
let result = KaishVfs.execute(args_with_subcmd("status"), &mut ctx).await;
assert!(
result.ok(),
"status is introspection — it answers in any session: err='{}'",
result.err
);
let out = result.text_out();
assert!(
out.lines().any(|l| l == "mode\tdirect"),
"expected mode=direct row, got: {}",
out
);
assert!(
out.lines().any(|l| l == "budget\tunlimited"),
"expected budget row, got: {}",
out
);
}
#[tokio::test]
async fn test_diff_without_overlay_returns_error() {
let mut ctx = make_ctx_no_overlay();
let result = KaishVfs.execute(args_with_subcmd("diff"), &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_commit_without_overlay_returns_error() {
let mut ctx = make_ctx_no_overlay();
let result = KaishVfs.execute(args_with_subcmd("commit"), &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_reset_without_overlay_returns_error() {
let mut ctx = make_ctx_no_overlay();
let result = KaishVfs.execute(args_with_subcmd("reset"), &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_unknown_subcommand() {
let mut ctx = make_ctx_no_overlay();
let result = KaishVfs.execute(args_with_subcmd("bogus"), &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("unknown subcommand"));
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1024), "1.0 KiB");
assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
assert_eq!(format_bytes(64 * 1024 * 1024), "64 MiB");
}
}