storkit: merge 420_story_loc_for_a_specified_file_bot_command_and_web_ui_slash_command
This commit is contained in:
@@ -647,6 +647,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
"git",
|
||||
"overview",
|
||||
"rebuild",
|
||||
"loc",
|
||||
]);
|
||||
|
||||
if (knownCommands.has(cmd)) {
|
||||
|
||||
@@ -38,20 +38,48 @@ const EXCLUDED_FILENAMES: &[&str] = &[
|
||||
];
|
||||
|
||||
pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
||||
let top_n = if ctx.args.is_empty() {
|
||||
DEFAULT_TOP_N
|
||||
let args = ctx.args.trim();
|
||||
|
||||
if args.is_empty() {
|
||||
return Some(loc_top_n(ctx.project_root, DEFAULT_TOP_N));
|
||||
}
|
||||
|
||||
let first_token = args.split_whitespace().next().unwrap_or("");
|
||||
Some(match first_token.parse::<usize>() {
|
||||
Ok(0) => format!(
|
||||
"Usage: `loc [N]` or `loc <filepath>` — show top N source files by line count (default {DEFAULT_TOP_N}), or line count for a specific file"
|
||||
),
|
||||
Ok(n) => loc_top_n(ctx.project_root, n),
|
||||
Err(_) => loc_single_file(ctx.project_root, args),
|
||||
})
|
||||
}
|
||||
|
||||
/// Count lines in a single file resolved relative to `project_root`.
|
||||
pub(crate) fn loc_single_file(project_root: &std::path::Path, file_arg: &str) -> String {
|
||||
let path = if std::path::Path::new(file_arg).is_absolute() {
|
||||
std::path::PathBuf::from(file_arg)
|
||||
} else {
|
||||
match ctx.args.split_whitespace().next().and_then(|s| s.parse::<usize>().ok()) {
|
||||
Some(n) if n > 0 => n,
|
||||
_ => {
|
||||
return Some(format!(
|
||||
"Usage: `loc [N]` — show top N source files by line count (default {DEFAULT_TOP_N})"
|
||||
));
|
||||
}
|
||||
}
|
||||
project_root.join(file_arg)
|
||||
};
|
||||
|
||||
let mut files: Vec<(usize, String)> = WalkDir::new(ctx.project_root)
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(content) => {
|
||||
let lines = content.lines().count();
|
||||
let display = path
|
||||
.strip_prefix(project_root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy();
|
||||
format!("`{display}` — {lines} lines")
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
format!("File not found: `{file_arg}`")
|
||||
}
|
||||
Err(e) => format!("Error reading `{file_arg}`: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn loc_top_n(project_root: &std::path::Path, top_n: usize) -> String {
|
||||
let mut files: Vec<(usize, String)> = WalkDir::new(project_root)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
@@ -66,7 +94,7 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
||||
// ".storkit/worktrees").
|
||||
let rel = e
|
||||
.path()
|
||||
.strip_prefix(ctx.project_root)
|
||||
.strip_prefix(project_root)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if SKIP_PATH_COMPONENTS.iter().any(|s| rel.contains(s)) {
|
||||
@@ -98,7 +126,7 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
// Make path relative to project_root for display.
|
||||
let rel = path
|
||||
.strip_prefix(ctx.project_root)
|
||||
.strip_prefix(project_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
@@ -110,14 +138,14 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
||||
files.truncate(top_n);
|
||||
|
||||
if files.is_empty() {
|
||||
return Some("No source files found.".to_string());
|
||||
return "No source files found.".to_string();
|
||||
}
|
||||
|
||||
let mut out = format!("**Top {} files by line count**\n\n", files.len());
|
||||
for (rank, (lines, path)) in files.iter().enumerate() {
|
||||
out.push_str(&format!("{}. `{}` — {} lines\n", rank + 1, path, lines));
|
||||
}
|
||||
Some(out)
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns true for file extensions considered source/text files.
|
||||
@@ -242,17 +270,55 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_invalid_arg_returns_usage() {
|
||||
fn loc_zero_arg_returns_usage() {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "abc");
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "0");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"invalid arg should show usage: {output}"
|
||||
"loc 0 should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_filepath_returns_line_count() {
|
||||
use std::io::Write as _;
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let src = dir.path().join("hello.rs");
|
||||
{
|
||||
let mut f = std::fs::File::create(&src).unwrap();
|
||||
for i in 0..42 {
|
||||
writeln!(f, "fn line_{i}() {{}}").unwrap();
|
||||
}
|
||||
}
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "hello.rs");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("42"),
|
||||
"should report 42 lines for hello.rs: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("hello.rs"),
|
||||
"output should mention the filename: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_filepath_nonexistent_returns_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, dir.path(), "does_not_exist.rs");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("not found") || output.contains("Error"),
|
||||
"nonexistent file should return a clear error: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -271,21 +337,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_zero_returns_usage() {
|
||||
let agents = Arc::new(AgentPool::new_test(3000));
|
||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."));
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "0");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"loc 0 should show usage (zero is not a valid count): {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_excludes_lockfiles_from_results() {
|
||||
use std::io::Write as _;
|
||||
|
||||
@@ -10,7 +10,7 @@ mod assign;
|
||||
mod cost;
|
||||
mod git;
|
||||
mod help;
|
||||
mod loc;
|
||||
pub(crate) mod loc;
|
||||
mod move_story;
|
||||
mod overview;
|
||||
mod show;
|
||||
@@ -117,7 +117,7 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
},
|
||||
BotCommand {
|
||||
name: "loc",
|
||||
description: "Show top source files by line count: `loc` (top 10) or `loc <N>`",
|
||||
description: "Show top source files by line count: `loc` (top 10), `loc <N>`, or `loc <filepath>` for a specific file",
|
||||
handler: loc::handle_loc,
|
||||
},
|
||||
BotCommand {
|
||||
|
||||
@@ -265,6 +265,17 @@ pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String,
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// MCP tool: count lines in a specific file relative to the project root.
|
||||
pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let file_path = args
|
||||
.get("file_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing required argument: file_path".to_string())?;
|
||||
|
||||
let project_root = ctx.state.get_project_root()?;
|
||||
Ok(crate::chat::commands::loc::loc_single_file(&project_root, file_path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1136,6 +1136,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "loc_file",
|
||||
"description": "Return the line count for a specific file. Path is resolved relative to the project root. Returns an error if the file does not exist.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the project root (e.g. 'server/src/main.rs')"
|
||||
}
|
||||
},
|
||||
"required": ["file_path"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -1226,6 +1240,8 @@ async fn handle_tools_call(
|
||||
"git_log" => git_tools::tool_git_log(&args, ctx).await,
|
||||
// Story triage
|
||||
"status" => status_tools::tool_status(&args, ctx).await,
|
||||
// File line count
|
||||
"loc_file" => diagnostics::tool_loc_file(&args, ctx),
|
||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||
};
|
||||
|
||||
@@ -1342,7 +1358,8 @@ mod tests {
|
||||
assert!(names.contains(&"git_commit"));
|
||||
assert!(names.contains(&"git_log"));
|
||||
assert!(names.contains(&"status"));
|
||||
assert_eq!(tools.len(), 49);
|
||||
assert!(names.contains(&"loc_file"));
|
||||
assert_eq!(tools.len(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user