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",
|
"git",
|
||||||
"overview",
|
"overview",
|
||||||
"rebuild",
|
"rebuild",
|
||||||
|
"loc",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (knownCommands.has(cmd)) {
|
if (knownCommands.has(cmd)) {
|
||||||
|
|||||||
@@ -38,20 +38,48 @@ const EXCLUDED_FILENAMES: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
||||||
let top_n = if ctx.args.is_empty() {
|
let args = ctx.args.trim();
|
||||||
DEFAULT_TOP_N
|
|
||||||
|
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 {
|
} else {
|
||||||
match ctx.args.split_whitespace().next().and_then(|s| s.parse::<usize>().ok()) {
|
project_root.join(file_arg)
|
||||||
Some(n) if n > 0 => n,
|
|
||||||
_ => {
|
|
||||||
return Some(format!(
|
|
||||||
"Usage: `loc [N]` — show top N source files by line count (default {DEFAULT_TOP_N})"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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)
|
.follow_links(false)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_entry(|e| {
|
.filter_entry(|e| {
|
||||||
@@ -66,7 +94,7 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
|||||||
// ".storkit/worktrees").
|
// ".storkit/worktrees").
|
||||||
let rel = e
|
let rel = e
|
||||||
.path()
|
.path()
|
||||||
.strip_prefix(ctx.project_root)
|
.strip_prefix(project_root)
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if SKIP_PATH_COMPONENTS.iter().any(|s| rel.contains(s)) {
|
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.
|
// Make path relative to project_root for display.
|
||||||
let rel = path
|
let rel = path
|
||||||
.strip_prefix(ctx.project_root)
|
.strip_prefix(project_root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned();
|
.into_owned();
|
||||||
@@ -110,14 +138,14 @@ pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
|||||||
files.truncate(top_n);
|
files.truncate(top_n);
|
||||||
|
|
||||||
if files.is_empty() {
|
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());
|
let mut out = format!("**Top {} files by line count**\n\n", files.len());
|
||||||
for (rank, (lines, path)) in files.iter().enumerate() {
|
for (rank, (lines, path)) in files.iter().enumerate() {
|
||||||
out.push_str(&format!("{}. `{}` — {} lines\n", rank + 1, path, lines));
|
out.push_str(&format!("{}. `{}` — {} lines\n", rank + 1, path, lines));
|
||||||
}
|
}
|
||||||
Some(out)
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true for file extensions considered source/text files.
|
/// Returns true for file extensions considered source/text files.
|
||||||
@@ -242,17 +270,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn loc_invalid_arg_returns_usage() {
|
fn loc_zero_arg_returns_usage() {
|
||||||
let agents = Arc::new(AgentPool::new_test(3000));
|
let agents = Arc::new(AgentPool::new_test(3000));
|
||||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.parent()
|
.parent()
|
||||||
.unwrap_or(std::path::Path::new("."));
|
.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();
|
let output = handle_loc(&ctx).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Usage"),
|
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]
|
#[test]
|
||||||
fn loc_excludes_lockfiles_from_results() {
|
fn loc_excludes_lockfiles_from_results() {
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ mod assign;
|
|||||||
mod cost;
|
mod cost;
|
||||||
mod git;
|
mod git;
|
||||||
mod help;
|
mod help;
|
||||||
mod loc;
|
pub(crate) mod loc;
|
||||||
mod move_story;
|
mod move_story;
|
||||||
mod overview;
|
mod overview;
|
||||||
mod show;
|
mod show;
|
||||||
@@ -117,7 +117,7 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
},
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "loc",
|
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,
|
handler: loc::handle_loc,
|
||||||
},
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
|
|||||||
@@ -265,6 +265,17 @@ pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String,
|
|||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -1136,6 +1136,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
"required": ["story_id"]
|
"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,
|
"git_log" => git_tools::tool_git_log(&args, ctx).await,
|
||||||
// Story triage
|
// Story triage
|
||||||
"status" => status_tools::tool_status(&args, ctx).await,
|
"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}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1342,7 +1358,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"git_commit"));
|
assert!(names.contains(&"git_commit"));
|
||||||
assert!(names.contains(&"git_log"));
|
assert!(names.contains(&"git_log"));
|
||||||
assert!(names.contains(&"status"));
|
assert!(names.contains(&"status"));
|
||||||
assert_eq!(tools.len(), 49);
|
assert!(names.contains(&"loc_file"));
|
||||||
|
assert_eq!(tools.len(), 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user