diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index e3b4bfeb..9ce47d60 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -647,6 +647,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { "git", "overview", "rebuild", + "loc", ]); if (knownCommands.has(cmd)) { diff --git a/server/src/chat/commands/loc.rs b/server/src/chat/commands/loc.rs index 0d9b5afa..a56d0a5a 100644 --- a/server/src/chat/commands/loc.rs +++ b/server/src/chat/commands/loc.rs @@ -38,20 +38,48 @@ const EXCLUDED_FILENAMES: &[&str] = &[ ]; pub(super) fn handle_loc(ctx: &CommandContext) -> Option { - 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::() { + Ok(0) => format!( + "Usage: `loc [N]` or `loc ` — 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::().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 { // ".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 { } // 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 { 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 _; diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index a0cfb8eb..3a788f14 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -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 `", + description: "Show top source files by line count: `loc` (top 10), `loc `, or `loc ` for a specific file", handler: loc::handle_loc, }, BotCommand { diff --git a/server/src/http/mcp/diagnostics.rs b/server/src/http/mcp/diagnostics.rs index 6c87d937..4e20da9e 100644 --- a/server/src/http/mcp/diagnostics.rs +++ b/server/src/http/mcp/diagnostics.rs @@ -265,6 +265,17 @@ pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result Result { + 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::*; diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index b8147b8f..8c403548 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -1136,6 +1136,20 @@ fn handle_tools_list(id: Option) -> 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]