storkit: merge 420_story_loc_for_a_specified_file_bot_command_and_web_ui_slash_command

This commit is contained in:
dave
2026-03-28 08:56:06 +00:00
parent f4ce0e017b
commit d6f82393f5
5 changed files with 116 additions and 36 deletions
+84 -33
View File
@@ -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 _;
+2 -2
View File
@@ -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 {