restore: reset past source tree deletion, apply pending work
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
//! Handler for the `loc` command — top source files by line count.
|
||||
|
||||
use super::CommandContext;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const DEFAULT_TOP_N: usize = 10;
|
||||
|
||||
/// Directories to skip during traversal.
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"target",
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"coverage",
|
||||
"test-results",
|
||||
];
|
||||
|
||||
/// Path components that indicate a worktree path that should be skipped.
|
||||
const SKIP_PATH_COMPONENTS: &[&str] = &[".storkit/worktrees"];
|
||||
|
||||
pub(super) fn handle_loc(ctx: &CommandContext) -> Option<String> {
|
||||
let top_n = if ctx.args.is_empty() {
|
||||
DEFAULT_TOP_N
|
||||
} 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})"
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut files: Vec<(usize, String)> = WalkDir::new(ctx.project_root)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| {
|
||||
if e.file_type().is_dir() {
|
||||
let name = e.file_name().to_string_lossy();
|
||||
if SKIP_DIRS.iter().any(|s| *s == name.as_ref()) {
|
||||
return false;
|
||||
}
|
||||
// Skip .storkit/worktrees
|
||||
let path = e.path().to_string_lossy();
|
||||
if SKIP_PATH_COMPONENTS.iter().any(|s| path.contains(s)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
if !entry.file_type().is_file() {
|
||||
return None;
|
||||
}
|
||||
let path = entry.path();
|
||||
// Skip binary/generated files without a recognisable text extension.
|
||||
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
||||
if !is_source_extension(ext) {
|
||||
return None;
|
||||
}
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let line_count = content.lines().count();
|
||||
if line_count == 0 {
|
||||
return None;
|
||||
}
|
||||
// Make path relative to project_root for display.
|
||||
let rel = path
|
||||
.strip_prefix(ctx.project_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Some((line_count, rel))
|
||||
})
|
||||
.collect();
|
||||
|
||||
files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
files.truncate(top_n);
|
||||
|
||||
if files.is_empty() {
|
||||
return Some("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)
|
||||
}
|
||||
|
||||
/// Returns true for file extensions considered source/text files.
|
||||
fn is_source_extension(ext: &str) -> bool {
|
||||
matches!(
|
||||
ext,
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "h"
|
||||
| "hpp" | "cs" | "rb" | "swift" | "kt" | "scala" | "hs" | "ml" | "ex" | "exs"
|
||||
| "clj" | "lua" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "toml" | "yaml"
|
||||
| "yml" | "json" | "md" | "html" | "css" | "scss" | "less" | "sql" | "graphql"
|
||||
| "proto" | "tf" | "hcl" | "nix" | "r" | "jl" | "dart" | "vue" | "svelte"
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn make_ctx<'a>(
|
||||
agents: &'a Arc<AgentPool>,
|
||||
ambient_rooms: &'a Arc<Mutex<HashSet<String>>>,
|
||||
project_root: &'a std::path::Path,
|
||||
args: &'a str,
|
||||
) -> super::super::CommandContext<'a> {
|
||||
super::super::CommandContext {
|
||||
bot_name: "Timmy",
|
||||
args,
|
||||
project_root,
|
||||
agents,
|
||||
ambient_rooms,
|
||||
room_id: "!test:example.com",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_command_is_registered() {
|
||||
use super::super::commands;
|
||||
let found = commands().iter().any(|c| c.name == "loc");
|
||||
assert!(found, "loc command must be in the registry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("loc"), "help should list loc command: {output}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_default_returns_top_10() {
|
||||
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, "");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("Top"),
|
||||
"output should contain 'Top': {output}"
|
||||
);
|
||||
// At most 10 entries (numbered lines "1." through "10.")
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 10, "default should return at most 10 files, got {count}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_with_arg_5_returns_at_most_5() {
|
||||
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, "5");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 5, "loc 5 should return at most 5 files, got {count}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_with_arg_20_returns_at_most_20() {
|
||||
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, "20");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 20, "loc 20 should return at most 20 files, got {count}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_output_contains_rank_and_line_count() {
|
||||
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, "");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
// Each entry should have "N. `path` — N lines"
|
||||
assert!(
|
||||
output.contains("1. `"),
|
||||
"first result should start with rank: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("lines"),
|
||||
"output should mention 'lines': {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_invalid_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 output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"invalid arg should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loc_skips_worktrees_directory() {
|
||||
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, "");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
assert!(
|
||||
!output.contains(".storkit/worktrees"),
|
||||
"output must not include paths inside worktrees: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod assign;
|
||||
mod cost;
|
||||
mod git;
|
||||
mod help;
|
||||
mod loc;
|
||||
mod move_story;
|
||||
mod overview;
|
||||
mod show;
|
||||
@@ -114,6 +115,11 @@ pub fn commands() -> &'static [BotCommand] {
|
||||
description: "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total",
|
||||
handler: cost::handle_cost,
|
||||
},
|
||||
BotCommand {
|
||||
name: "loc",
|
||||
description: "Show top source files by line count: `loc` (top 10) or `loc <N>`",
|
||||
handler: loc::handle_loc,
|
||||
},
|
||||
BotCommand {
|
||||
name: "move",
|
||||
description: "Move a work item to a pipeline stage: `move <number> <stage>` (stages: backlog, current, qa, merge, done)",
|
||||
|
||||
Reference in New Issue
Block a user