//! The `reposhape` command summarizes repository trees. #![allow(clippy::multiple_crate_versions)] use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use configsupport::{ CliError, CommonArgs, ExitCode, RenderMode, parse_color_choice, parse_format_choice, print_json, print_quick_help_error, print_structured, }; use ignore::WalkBuilder; use lexopt::prelude::{Long, Short, Value as ArgValue}; use serde::Serialize; use serde_json::Value; const MAX_SCANNED_FILES: usize = 250_000; const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024; const HELP: &str = "\ Detect repository ecosystems, manifests, command entrypoints, and CI hints. Usage: reposhape [OPTIONS] [PATH] Options: --format Structured output format: text, json, toon --json Shortcut for --format json --toon Shortcut for --format toon --color Control ANSI color output: auto, never --quiet Suppress non-essential status output --max-depth Optional traversal depth limit --hidden Include hidden files and directories -h, --help Show this help text -V, --version Show the command version Examples: reposhape . reposhape . --json | ConvertFrom-Json reposhape C:\\src\\repo --max-depth 4 "; #[derive(Debug, Clone)] struct Cli { common: CommonArgs, max_depth: Option, include_hidden: bool, path: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ParseOutcome { Help, Version, Run, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct RepoSummary { root: String, ecosystems: Vec, manifests: Vec, commands: Vec, entrypoints: Vec, ci: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct Ecosystem { name: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct Manifest { ecosystem: String, kind: String, path: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct CommandHint { ecosystem: String, build: Vec, test: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct EntryPoint { ecosystem: String, kind: String, path: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct CiHint { provider: String, kind: String, path: String, } #[derive(Debug, Default)] struct CommandAccumulator { build: BTreeSet, test: BTreeSet, } #[derive(Debug, Default)] struct ScriptedLayoutState { has_src: bool, has_tests: bool, has_docs: bool, } struct RepoScanState<'a> { ecosystems: &'a mut BTreeSet, manifests: &'a mut Vec, command_map: &'a mut BTreeMap, entrypoints: &'a mut Vec, ci: &'a mut Vec, scripted_layout: &'a mut ScriptedLayoutState, } /// Parses CLI arguments and returns a process exit code. #[must_use] pub fn main_entry() -> i32 { match parse_cli_from(std::env::args_os()) { Ok((ParseOutcome::Help, _)) => { print!("{HELP}"); ExitCode::Success.as_i32() } Ok((ParseOutcome::Version, _)) => { println!("reposhape {}", env!("CARGO_PKG_VERSION")); ExitCode::Success.as_i32() } Ok((ParseOutcome::Run, cli)) => match run(&cli) { Ok(code) => code.as_i32(), Err(error) => { print_quick_help_error(&error, HELP); error.exit_code().as_i32() } }, Err(error) => { print_quick_help_error(&error, HELP); error.exit_code().as_i32() } } } fn parse_cli_from(args: I) -> Result<(ParseOutcome, Cli), CliError> where I: IntoIterator, T: Into, { let mut parser = lexopt::Parser::from_iter(args); let mut cli = Cli { common: CommonArgs::default(), max_depth: None, include_hidden: false, path: None, }; while let Some(argument) = parser .next() .map_err(|error| CliError::usage(error.to_string()))? { match argument { Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)), Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)), Long("json") => cli.common.set_render_mode(RenderMode::Json), Long("toon") => cli.common.set_render_mode(RenderMode::Toon), Long("format") => { let value = parser_value_string(&mut parser, "--format")?; cli.common.set_render_mode(parse_format_choice(&value)?); } Long("quiet") => cli.common.quiet = true, Long("hidden") => cli.include_hidden = true, Long("color") => { cli.common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; } Long("max-depth") => { cli.max_depth = Some(parse_usize_flag( "--max-depth", &parser_value_string(&mut parser, "--max-depth")?, )?); } ArgValue(path) if cli.path.is_none() => cli.path = Some(PathBuf::from(path)), _ => { return Err(CliError::usage( "unsupported argument; use --help to see available options", )); } } } Ok((ParseOutcome::Run, cli)) } fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { let value = parser .value() .map_err(|error| CliError::usage(error.to_string()))?; value.into_string().map_err(|invalid| { CliError::usage(format!( "{flag} expects UTF-8 text, got '{}'", invalid.to_string_lossy() )) }) } fn parse_usize_flag(flag: &str, value: &str) -> Result { value .parse::() .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } fn run(cli: &Cli) -> Result { let summary = inspect_repository(cli)?; match cli.common.render_mode() { RenderMode::Json => print_json(&summary)?, RenderMode::Toon => print_structured(&summary, RenderMode::Toon)?, RenderMode::Text => print!("{}", render_summary(&summary)), } Ok(ExitCode::Success) } fn inspect_repository(cli: &Cli) -> Result { inspect_repository_with_file_cap(cli, MAX_SCANNED_FILES) } fn inspect_repository_with_file_cap( cli: &Cli, max_scanned_files: usize, ) -> Result { let root = cli .path .clone() .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); if !root.exists() { return Err(CliError::runtime(format!( "repository path does not exist: {}", root.display() ))); } let mut builder = WalkBuilder::new(&root); builder.hidden(!cli.include_hidden); builder.git_ignore(true); builder.git_global(true); builder.git_exclude(true); if let Some(depth) = cli.max_depth { builder.max_depth(Some(depth)); } let mut ecosystems = BTreeSet::::new(); let mut manifests = Vec::::new(); let mut entrypoints = Vec::::new(); let mut ci = Vec::::new(); let mut command_map = BTreeMap::::new(); let mut scripted_layout = ScriptedLayoutState::default(); let mut scanned_files = 0_usize; let mut state = RepoScanState { ecosystems: &mut ecosystems, manifests: &mut manifests, command_map: &mut command_map, entrypoints: &mut entrypoints, ci: &mut ci, scripted_layout: &mut scripted_layout, }; validate_github_workflows_dir(&root)?; for entry in builder.build() { let entry = entry .map_err(|error| CliError::runtime(format!("failed to walk repository: {error}")))?; if entry.path().is_file() { scanned_files += 1; if scanned_files > max_scanned_files { return Err(CliError::runtime(format!( "reposhape scan exceeded {max_scanned_files} files; narrow the scan with --max-depth or a smaller root" ))); } scan_file(&root, entry.path(), &mut state)?; } } detect_github_actions(&root, state.ci)?; state .scripted_layout .finish(state.ecosystems, state.entrypoints); dedup_manifests(state.manifests); dedup_entrypoints(state.entrypoints); dedup_ci(state.ci); let ecosystems = ecosystems .into_iter() .map(|name| Ecosystem { name }) .collect::>(); let commands = command_map .into_iter() .map(|(ecosystem, hints)| CommandHint { ecosystem, build: hints.build.into_iter().collect(), test: hints.test.into_iter().collect(), }) .collect::>(); Ok(RepoSummary { root: root.display().to_string(), ecosystems, manifests, commands, entrypoints, ci, }) } #[allow(clippy::too_many_lines)] fn scan_file(root: &Path, file: &Path, state: &mut RepoScanState<'_>) -> Result<(), CliError> { let relative = relative_path(root, file); let normalized_relative = normalize_separators(&relative); let Some(file_name) = file.file_name().and_then(|value| value.to_str()) else { return Ok(()); }; match file_name { "Cargo.toml" => { add_ecosystem(state.ecosystems, "cargo"); add_manifest(state.manifests, "cargo", "cargo_toml", &relative); add_commands(state.command_map, "cargo", "cargo build", "cargo test"); detect_cargo_entrypoints(root, file, state.entrypoints); } "package.json" => { detect_node_workspace( root, file, state.ecosystems, state.manifests, state.command_map, state.entrypoints, )?; } "package-lock.json" => { add_ecosystem(state.ecosystems, "npm"); add_manifest(state.manifests, "npm", "package_lock", &relative); } "pnpm-lock.yaml" => { add_ecosystem(state.ecosystems, "pnpm"); add_manifest(state.manifests, "pnpm", "pnpm_lock", &relative); } "yarn.lock" => { add_ecosystem(state.ecosystems, "yarn"); add_manifest(state.manifests, "yarn", "yarn_lock", &relative); } "bun.lock" | "bun.lockb" => { add_ecosystem(state.ecosystems, "bun"); add_manifest(state.manifests, "bun", "bun_lock", &relative); } "pyproject.toml" => { detect_python_project( root, file, state.ecosystems, state.manifests, state.command_map, state.entrypoints, )?; } "go.mod" => { add_ecosystem(state.ecosystems, "go"); add_manifest(state.manifests, "go", "go_mod", &relative); add_commands(state.command_map, "go", "go build ./...", "go test ./..."); detect_go_entrypoints(root, file, state.entrypoints); } "CMakeLists.txt" => { add_ecosystem(state.ecosystems, "cmake"); add_manifest(state.manifests, "cmake", "cmake_lists", &relative); add_commands( state.command_map, "cmake", "cmake -S . -B build && cmake --build build", "ctest --test-dir build", ); } "Makefile" | "makefile" | "GNUmakefile" => { add_ecosystem(state.ecosystems, "make"); add_manifest(state.manifests, "make", "makefile", &relative); detect_make_commands(file, state.command_map)?; } _ => { if has_extension_case_insensitive(file_name, "csproj") { add_ecosystem(state.ecosystems, "dotnet"); add_manifest(state.manifests, "dotnet", "csproj", &relative); add_entrypoint(state.entrypoints, "dotnet", "project", &relative); add_command_line( state.command_map, "dotnet", "build", format!("dotnet build {relative}"), ); add_command_line( state.command_map, "dotnet", "test", format!("dotnet test {relative}"), ); } else if has_extension_case_insensitive(file_name, "sln") { add_ecosystem(state.ecosystems, "dotnet"); add_manifest(state.manifests, "dotnet", "sln", &relative); add_entrypoint(state.entrypoints, "dotnet", "solution", &relative); add_command_line( state.command_map, "dotnet", "build", format!("dotnet build {relative}"), ); add_command_line( state.command_map, "dotnet", "test", format!("dotnet test {relative}"), ); } else if file_name.starts_with("requirements") && has_extension_case_insensitive(file_name, "txt") { add_ecosystem(state.ecosystems, "python"); add_manifest(state.manifests, "python", "requirements", &relative); add_command_line(state.command_map, "python", "test", "pytest".to_string()); } else if normalized_relative.starts_with(".github/workflows/") && is_yaml_path(&relative) { state.ci.push(CiHint { provider: "github_actions".to_string(), kind: "workflow".to_string(), path: relative.clone(), }); } } } scan_scripted_layout_path( &relative, &normalized_relative, state.ecosystems, state.manifests, state.command_map, state.entrypoints, state.scripted_layout, ); Ok(()) } fn scan_scripted_layout_path( relative: &str, normalized_relative: &str, ecosystems: &mut BTreeSet, manifests: &mut Vec, command_map: &mut BTreeMap, entrypoints: &mut Vec, scripted_layout: &mut ScriptedLayoutState, ) { match normalized_relative { "build.ps1" => { add_ecosystem(ecosystems, "scripted"); add_manifest(manifests, "scripted", "build_script", relative); add_command_line( command_map, "scripted", "build", format!("pwsh -NoProfile -File {relative}"), ); add_entrypoint(entrypoints, "scripted", "build_script", relative); } "build.cmd" | "build.bat" => { add_ecosystem(ecosystems, "scripted"); add_manifest(manifests, "scripted", "build_script", relative); add_command_line( command_map, "scripted", "build", format!("cmd /d /c {relative}"), ); add_entrypoint(entrypoints, "scripted", "build_script", relative); } "build.sh" => { add_ecosystem(ecosystems, "scripted"); add_manifest(manifests, "scripted", "build_script", relative); add_command_line(command_map, "scripted", "build", format!("sh {relative}")); add_entrypoint(entrypoints, "scripted", "build_script", relative); } "test.ps1" => { add_ecosystem(ecosystems, "scripted"); add_manifest(manifests, "scripted", "test_script", relative); add_command_line( command_map, "scripted", "test", format!("pwsh -NoProfile -File {relative}"), ); add_entrypoint(entrypoints, "scripted", "test_script", relative); } "test.cmd" | "test.bat" => { add_ecosystem(ecosystems, "scripted"); add_manifest(manifests, "scripted", "test_script", relative); add_command_line( command_map, "scripted", "test", format!("cmd /d /c {relative}"), ); add_entrypoint(entrypoints, "scripted", "test_script", relative); } "test.sh" => { add_ecosystem(ecosystems, "scripted"); add_manifest(manifests, "scripted", "test_script", relative); add_command_line(command_map, "scripted", "test", format!("sh {relative}")); add_entrypoint(entrypoints, "scripted", "test_script", relative); } "README.md" => add_entrypoint(entrypoints, "scripted", "readme", relative), _ => {} } if normalized_relative.starts_with("src/") { scripted_layout.has_src = true; } if normalized_relative.starts_with("tests/") { scripted_layout.has_tests = true; } if normalized_relative.starts_with("docs/") { scripted_layout.has_docs = true; } } impl ScriptedLayoutState { fn finish(&self, ecosystems: &mut BTreeSet, entrypoints: &mut Vec) { if self.has_src { add_ecosystem(ecosystems, "scripted"); add_entrypoint(entrypoints, "scripted", "source_dir", "src"); } if self.has_tests { add_ecosystem(ecosystems, "scripted"); add_entrypoint(entrypoints, "scripted", "test_dir", "tests"); } if self.has_docs { add_ecosystem(ecosystems, "scripted"); add_entrypoint(entrypoints, "scripted", "docs_dir", "docs"); } } } fn validate_github_workflows_dir(root: &Path) -> Result<(), CliError> { let workflows = root.join(".github").join("workflows"); if workflows.exists() && !workflows.is_dir() { return Err(CliError::runtime(format!( "failed to read GitHub workflow directory {}: path is not a directory", workflows.display() ))); } Ok(()) } fn detect_cargo_entrypoints(root: &Path, manifest: &Path, entrypoints: &mut Vec) { let base = manifest.parent().unwrap_or(root); let main = base.join("src").join("main.rs"); if main.exists() { add_entrypoint( entrypoints, "cargo", "rust_bin", &relative_path(root, &main), ); } let lib = base.join("src").join("lib.rs"); if lib.exists() { add_entrypoint(entrypoints, "cargo", "rust_lib", &relative_path(root, &lib)); } } fn detect_node_workspace( root: &Path, package_json: &Path, ecosystems: &mut BTreeSet, manifests: &mut Vec, command_map: &mut BTreeMap, entrypoints: &mut Vec, ) -> Result<(), CliError> { let relative = relative_path(root, package_json); let raw = read_bounded_text_file(package_json, "package.json")?; let parsed = serde_json::from_str::(&raw).map_err(|error| { CliError::runtime(format!( "failed to parse package.json {}: {error}", package_json.display() )) })?; let manager = detect_node_manager(package_json, &parsed); add_ecosystem(ecosystems, manager); add_manifest(manifests, manager, "package_json", &relative); add_default_node_commands(command_map, manager, &parsed); add_node_entrypoints(root, package_json, manager, &parsed, entrypoints); Ok(()) } fn detect_node_manager(path: &Path, package_json: &Value) -> &'static str { let base = path.parent().unwrap_or_else(|| Path::new(".")); if base.join("pnpm-lock.yaml").exists() { return "pnpm"; } if base.join("yarn.lock").exists() { return "yarn"; } if base.join("bun.lock").exists() || base.join("bun.lockb").exists() { return "bun"; } let package_manager = package_json .get("packageManager") .and_then(Value::as_str) .unwrap_or_default(); if package_manager.starts_with("pnpm@") { "pnpm" } else if package_manager.starts_with("yarn@") { "yarn" } else if package_manager.starts_with("bun@") { "bun" } else { "npm" } } fn add_default_node_commands( command_map: &mut BTreeMap, manager: &str, package_json: &Value, ) { let scripts = package_json.get("scripts").and_then(Value::as_object); if let Some(scripts) = scripts { if scripts.contains_key("build") { add_command_line( command_map, manager, "build", node_script_command(manager, "build"), ); } if scripts.contains_key("test") { add_command_line( command_map, manager, "test", node_script_command(manager, "test"), ); } } } fn node_script_command(manager: &str, script: &str) -> String { match manager { "yarn" => format!("yarn {script}"), "bun" => format!("bun run {script}"), "pnpm" => format!("pnpm run {script}"), _ => format!("npm run {script}"), } } fn add_node_entrypoints( root: &Path, package_json_path: &Path, manager: &str, package_json: &Value, entrypoints: &mut Vec, ) { let base = package_json_path.parent().unwrap_or(root); if let Some(main) = package_json.get("main").and_then(Value::as_str) { let path = base.join(main); add_entrypoint( entrypoints, manager, "node_main", &relative_path(root, &path), ); } if let Some(bin) = package_json.get("bin") { match bin { Value::String(path_text) => { let path = base.join(path_text); add_entrypoint( entrypoints, manager, "node_bin", &relative_path(root, &path), ); } Value::Object(map) => { for value in map.values() { if let Some(path_text) = value.as_str() { let path = base.join(path_text); add_entrypoint( entrypoints, manager, "node_bin", &relative_path(root, &path), ); } } } _ => {} } } } fn detect_python_project( root: &Path, pyproject: &Path, ecosystems: &mut BTreeSet, manifests: &mut Vec, command_map: &mut BTreeMap, entrypoints: &mut Vec, ) -> Result<(), CliError> { let relative = relative_path(root, pyproject); add_ecosystem(ecosystems, "python"); add_manifest(manifests, "python", "pyproject_toml", &relative); add_commands(command_map, "python", "python -m build", "pytest"); let raw = read_bounded_text_file(pyproject, "pyproject")?; let parsed = raw.parse::().map_err(|error| { CliError::runtime(format!( "failed to parse pyproject {}: {error}", pyproject.display() )) })?; let scripts = parsed .get("project") .and_then(|value| value.get("scripts")) .and_then(toml::Value::as_table); if let Some(table) = scripts { for (name, _) in table { add_entrypoint( entrypoints, "python", "project_script", &format!("{relative}::{name}"), ); } } let poetry_scripts = parsed .get("tool") .and_then(|value| value.get("poetry")) .and_then(|value| value.get("scripts")) .and_then(toml::Value::as_table); if let Some(table) = poetry_scripts { for (name, _) in table { add_entrypoint( entrypoints, "python", "poetry_script", &format!("{relative}::{name}"), ); } } Ok(()) } #[cfg(test)] fn detect_scripted_layout( root: &Path, files: &[PathBuf], ecosystems: &mut BTreeSet, manifests: &mut Vec, command_map: &mut BTreeMap, entrypoints: &mut Vec, ) { let mut scripted_layout = ScriptedLayoutState::default(); for file in files { let relative = relative_path(root, file); let normalized_relative = normalize_separators(&relative); scan_scripted_layout_path( &relative, &normalized_relative, ecosystems, manifests, command_map, entrypoints, &mut scripted_layout, ); } scripted_layout.finish(ecosystems, entrypoints); dedup_manifests(manifests); dedup_entrypoints(entrypoints); } fn detect_go_entrypoints(root: &Path, go_mod: &Path, entrypoints: &mut Vec) { let base = go_mod.parent().unwrap_or(root); let main = base.join("main.go"); if main.exists() { add_entrypoint(entrypoints, "go", "go_main", &relative_path(root, &main)); } let cmd_dir = base.join("cmd"); if !cmd_dir.exists() { return; } if let Ok(entries) = fs::read_dir(cmd_dir) { for entry in entries.flatten() { let main_path = entry.path().join("main.go"); if main_path.exists() { add_entrypoint( entrypoints, "go", "go_cmd", &relative_path(root, &main_path), ); } } } } fn detect_make_commands( makefile: &Path, command_map: &mut BTreeMap, ) -> Result<(), CliError> { let content = read_bounded_text_file(makefile, "Makefile")?; add_command_line(command_map, "make", "build", "make".to_string()); if content.lines().any(|line| { let trimmed = line.trim_start(); trimmed.starts_with("test:") || trimmed.starts_with("check:") }) { add_command_line(command_map, "make", "test", "make test".to_string()); } Ok(()) } fn detect_github_actions(root: &Path, ci: &mut Vec) -> Result<(), CliError> { let workflows = root.join(".github").join("workflows"); if !workflows.exists() { return Ok(()); } let entries = fs::read_dir(&workflows).map_err(|error| { CliError::runtime(format!( "failed to read GitHub workflow directory {}: {error}", workflows.display() )) })?; for entry in entries { let entry = entry.map_err(|error| { CliError::runtime(format!( "failed to read workflow entry in {}: {error}", workflows.display() )) })?; let path = entry.path(); let relative = relative_path(root, &path); if is_yaml_path(&relative) { ci.push(CiHint { provider: "github_actions".to_string(), kind: "workflow".to_string(), path: relative, }); } } dedup_ci(ci); Ok(()) } fn has_extension_case_insensitive(path_like: &str, extension: &str) -> bool { Path::new(path_like) .extension() .and_then(|value| value.to_str()) .is_some_and(|value| value.eq_ignore_ascii_case(extension)) } fn is_yaml_path(path: &str) -> bool { has_extension_case_insensitive(path, "yml") || has_extension_case_insensitive(path, "yaml") } fn add_ecosystem(ecosystems: &mut BTreeSet, ecosystem: &str) { ecosystems.insert(ecosystem.to_string()); } fn add_manifest(manifests: &mut Vec, ecosystem: &str, kind: &str, path: &str) { manifests.push(Manifest { ecosystem: ecosystem.to_string(), kind: kind.to_string(), path: path.to_string(), }); } fn add_entrypoint(entrypoints: &mut Vec, ecosystem: &str, kind: &str, path: &str) { entrypoints.push(EntryPoint { ecosystem: ecosystem.to_string(), kind: kind.to_string(), path: path.to_string(), }); } fn add_commands( command_map: &mut BTreeMap, ecosystem: &str, build: &str, test: &str, ) { add_command_line(command_map, ecosystem, "build", build.to_string()); add_command_line(command_map, ecosystem, "test", test.to_string()); } fn add_command_line( command_map: &mut BTreeMap, ecosystem: &str, kind: &str, value: String, ) { let entry = command_map.entry(ecosystem.to_string()).or_default(); match kind { "build" => { entry.build.insert(value); } "test" => { entry.test.insert(value); } _ => {} } } fn dedup_manifests(manifests: &mut Vec) { manifests.sort_by(|left, right| { ( left.ecosystem.as_str(), left.kind.as_str(), left.path.as_str(), ) .cmp(&( right.ecosystem.as_str(), right.kind.as_str(), right.path.as_str(), )) }); manifests.dedup_by(|left, right| { left.ecosystem == right.ecosystem && left.kind == right.kind && left.path == right.path }); } fn dedup_entrypoints(entrypoints: &mut Vec) { entrypoints.sort_by(|left, right| { ( left.ecosystem.as_str(), left.kind.as_str(), left.path.as_str(), ) .cmp(&( right.ecosystem.as_str(), right.kind.as_str(), right.path.as_str(), )) }); entrypoints.dedup_by(|left, right| { left.ecosystem == right.ecosystem && left.kind == right.kind && left.path == right.path }); } fn dedup_ci(ci: &mut Vec) { ci.sort_by(|left, right| { ( left.provider.as_str(), left.kind.as_str(), left.path.as_str(), ) .cmp(&( right.provider.as_str(), right.kind.as_str(), right.path.as_str(), )) }); ci.dedup_by(|left, right| { left.provider == right.provider && left.kind == right.kind && left.path == right.path }); } fn read_bounded_text_file(path: &Path, label: &str) -> Result { read_bounded_text_file_with_limit(path, label, MAX_MANIFEST_BYTES) } fn read_bounded_text_file_with_limit( path: &Path, label: &str, max_manifest_bytes: u64, ) -> Result { let metadata = fs::metadata(path).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { CliError::runtime(format!("failed to read {}: {error}", path.display())) } else { CliError::runtime(format!("failed to inspect {}: {error}", path.display())) } })?; if metadata.len() > max_manifest_bytes { return Err(CliError::runtime(format!( "refusing to read {label} {} because it is {} bytes; reposhape manifest files are capped at {max_manifest_bytes} bytes", path.display(), metadata.len() ))); } fs::read_to_string(path) .map_err(|error| CliError::runtime(format!("failed to read {}: {error}", path.display()))) } fn relative_path(root: &Path, path: &Path) -> String { path.strip_prefix(root) .unwrap_or(path) .display() .to_string() } fn normalize_separators(path: &str) -> String { path.replace('\\', "/") } fn render_summary(summary: &RepoSummary) -> String { let mut output = format!( "root={} ecosystems={} manifests={} commands={} entrypoints={} ci={}\n", summary.root, render_pipe_list( &summary .ecosystems .iter() .map(|item| item.name.clone()) .collect::>() ), summary.manifests.len(), summary.commands.len(), summary.entrypoints.len(), summary.ci.len() ); for manifest in summary.manifests.iter().take(8) { let _ = writeln!( output, "manifest ecosystem={} kind={} path={}", manifest.ecosystem, manifest.kind, manifest.path ); } append_omitted_line(&mut output, "manifest", summary.manifests.len(), 8); for command in summary.commands.iter().take(6) { let _ = writeln!( output, "command ecosystem={} build={} test={}", command.ecosystem, render_pipe_list(&command.build), render_pipe_list(&command.test) ); } append_omitted_line(&mut output, "command", summary.commands.len(), 6); for entrypoint in summary.entrypoints.iter().take(12) { let _ = writeln!( output, "entrypoint ecosystem={} kind={} path={}", entrypoint.ecosystem, entrypoint.kind, entrypoint.path ); } append_omitted_line(&mut output, "entrypoint", summary.entrypoints.len(), 12); for pipeline in summary.ci.iter().take(6) { let _ = writeln!( output, "ci provider={} kind={} path={}", pipeline.provider, pipeline.kind, pipeline.path ); } append_omitted_line(&mut output, "ci", summary.ci.len(), 6); output } fn append_omitted_line(output: &mut String, label: &str, total: usize, shown: usize) { if total > shown { let _ = writeln!(output, "{}_omitted={}", label, total - shown); } } fn render_pipe_list(values: &[String]) -> String { if values.is_empty() { "-".to_string() } else { values.join("|") } } #[cfg(test)] mod tests { use super::*; use std::collections::{BTreeMap, BTreeSet}; #[test] fn parse_cli_supports_depth_hidden_and_path() { let (_, cli) = parse_cli_from([ "reposhape", "--json", "--max-depth", "2", "--hidden", "demo", ]) .expect("cli"); assert!(cli.common.json); assert_eq!(cli.max_depth, Some(2)); assert!(cli.include_hidden); assert_eq!(cli.path, Some(PathBuf::from("demo"))); } #[test] fn node_manager_and_relative_path_helpers_work() { let temp = tempfile::tempdir().expect("tempdir"); fs::write( temp.path().join("package.json"), "{\"scripts\":{\"build\":\"tsc\",\"test\":\"vitest\"}}", ) .expect("package"); fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("lock"); let package = serde_json::from_str::( &fs::read_to_string(temp.path().join("package.json")).expect("read package"), ) .expect("json"); assert_eq!( detect_node_manager(&temp.path().join("package.json"), &package), "pnpm" ); assert_eq!( relative_path(temp.path(), &temp.path().join("package.json")), "package.json" ); } #[test] fn parse_cli_reports_help_version_and_usage_errors() { let (help, _) = parse_cli_from(["reposhape", "--help"]).expect("help"); assert_eq!(help, ParseOutcome::Help); let (version, _) = parse_cli_from(["reposhape", "--version"]).expect("version"); assert_eq!(version, ParseOutcome::Version); let missing_depth = parse_cli_from(["reposhape", "--max-depth"]).expect_err("missing"); assert!(missing_depth.to_string().contains("--max-depth")); let invalid_depth = parse_cli_from(["reposhape", "--max-depth", "abc"]).expect_err("invalid depth"); assert!( invalid_depth .to_string() .contains("invalid --max-depth value 'abc'") ); let invalid_color = parse_cli_from(["reposhape", "--color", "always"]).expect_err("invalid color"); assert!( invalid_color .to_string() .contains("invalid --color value 'always'") ); let unsupported = parse_cli_from(["reposhape", "--unknown"]).expect_err("unsupported"); assert!(unsupported.to_string().contains("unsupported argument")); } #[test] fn inspect_repository_reports_missing_path() { let temp = tempfile::tempdir().expect("tempdir"); let cli = Cli { common: CommonArgs::default(), max_depth: None, include_hidden: false, path: Some(temp.path().join("missing")), }; let error = inspect_repository(&cli).expect_err("missing path should fail"); assert!(error.to_string().contains("repository path does not exist")); } #[test] fn inspect_repository_rejects_unbounded_large_file_sets() { let temp = tempfile::tempdir().expect("tempdir"); for index in 0..=3 { fs::write(temp.path().join(format!("file-{index}.txt")), "x").expect("file"); } let cli = Cli { common: CommonArgs::default(), max_depth: None, include_hidden: false, path: Some(temp.path().to_path_buf()), }; let error = inspect_repository_with_file_cap(&cli, 2).expect_err("file cap should fail"); assert!( error .to_string() .contains("reposhape scan exceeded 2 files") ); } #[test] fn read_bounded_text_file_rejects_large_manifests() { let temp = tempfile::tempdir().expect("tempdir"); let path = temp.path().join("package.json"); fs::write(&path, vec![b' '; 129]).expect("large manifest"); let error = read_bounded_text_file_with_limit(&path, "package.json", 128) .expect_err("manifest cap"); assert!(error.to_string().contains("manifest files are capped")); } #[test] fn detect_node_manager_prefers_locks_then_package_manager_then_default() { let temp = tempfile::tempdir().expect("tempdir"); let package_path = temp.path().join("package.json"); assert_eq!( detect_node_manager(&package_path, &serde_json::json!({})), "npm" ); assert_eq!( detect_node_manager( &package_path, &serde_json::json!({"packageManager":"yarn@4.1.0"}) ), "yarn" ); fs::write(temp.path().join("bun.lock"), "").expect("bun lock"); assert_eq!( detect_node_manager( &package_path, &serde_json::json!({"packageManager":"pnpm@9.0.0"}) ), "bun" ); } #[test] fn default_node_commands_and_script_command_variants_work() { let mut command_map = BTreeMap::new(); add_default_node_commands( &mut command_map, "pnpm", &serde_json::json!({"scripts":{"build":"tsc"}}), ); let hints = command_map.get("pnpm").expect("pnpm hints"); assert!(hints.build.contains("pnpm run build")); assert!(hints.test.is_empty()); add_default_node_commands( &mut command_map, "pnpm", &serde_json::json!({"scripts":{"test":"vitest"}}), ); let hints = command_map.get("pnpm").expect("pnpm hints"); assert!(hints.test.contains("pnpm run test")); assert_eq!(node_script_command("yarn", "build"), "yarn build"); assert_eq!(node_script_command("bun", "build"), "bun run build"); assert_eq!(node_script_command("pnpm", "build"), "pnpm run build"); assert_eq!(node_script_command("npm", "build"), "npm run build"); } #[test] fn detect_node_workspace_supports_scripts_main_and_bin_object() { let temp = tempfile::tempdir().expect("tempdir"); let package_json = temp.path().join("package.json"); fs::write( &package_json, r#"{ "scripts":{"build":"tsc","test":"vitest"}, "main":"index.js", "bin":{"tool":"bin/tool.js","alt":"bin/alt.js"}, "packageManager":"npm@10.5.0" }"#, ) .expect("package"); fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("lock"); let mut ecosystems = BTreeSet::new(); let mut manifests = Vec::new(); let mut command_map = BTreeMap::new(); let mut entrypoints = Vec::new(); detect_node_workspace( temp.path(), &package_json, &mut ecosystems, &mut manifests, &mut command_map, &mut entrypoints, ) .expect("workspace detection"); assert!(ecosystems.contains("pnpm")); assert!(manifests.iter().any(|manifest| { manifest.ecosystem == "pnpm" && manifest.kind == "package_json" && manifest.path == "package.json" })); let hints = command_map.get("pnpm").expect("pnpm hints"); assert!(hints.build.contains("pnpm run build")); assert!(hints.test.contains("pnpm run test")); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.ecosystem == "pnpm" && entrypoint.kind == "node_main" && entrypoint.path == "index.js" })); assert_eq!( entrypoints .iter() .filter(|entrypoint| entrypoint.kind == "node_bin") .count(), 2 ); } #[test] fn detect_node_workspace_reports_read_and_parse_errors() { let temp = tempfile::tempdir().expect("tempdir"); let missing = temp.path().join("missing-package.json"); let mut ecosystems = BTreeSet::new(); let mut manifests = Vec::new(); let mut command_map = BTreeMap::new(); let mut entrypoints = Vec::new(); let missing_error = detect_node_workspace( temp.path(), &missing, &mut ecosystems, &mut manifests, &mut command_map, &mut entrypoints, ) .expect_err("missing package should fail"); assert!(missing_error.to_string().contains("failed to read")); let broken = temp.path().join("package.json"); fs::write(&broken, "{not-json").expect("broken package"); let parse_error = detect_node_workspace( temp.path(), &broken, &mut ecosystems, &mut manifests, &mut command_map, &mut entrypoints, ) .expect_err("broken package should fail"); assert!( parse_error .to_string() .contains("failed to parse package.json") ); } #[test] fn detect_python_project_collects_scripts_and_reports_parse_errors() { let temp = tempfile::tempdir().expect("tempdir"); let pyproject = temp.path().join("pyproject.toml"); fs::write( &pyproject, r#" [project] name = "demo" [project.scripts] serve = "demo:main" [tool.poetry.scripts] fmt = "demo:fmt" "#, ) .expect("pyproject"); let mut ecosystems = BTreeSet::new(); let mut manifests = Vec::new(); let mut command_map = BTreeMap::new(); let mut entrypoints = Vec::new(); detect_python_project( temp.path(), &pyproject, &mut ecosystems, &mut manifests, &mut command_map, &mut entrypoints, ) .expect("python project"); assert!(ecosystems.contains("python")); assert!(manifests.iter().any(|manifest| { manifest.ecosystem == "python" && manifest.kind == "pyproject_toml" && manifest.path == "pyproject.toml" })); let hints = command_map.get("python").expect("python hints"); assert!(hints.build.contains("python -m build")); assert!(hints.test.contains("pytest")); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.kind == "project_script" && entrypoint.path == "pyproject.toml::serve" })); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.kind == "poetry_script" && entrypoint.path == "pyproject.toml::fmt" })); fs::write(&pyproject, "[project\nname = \"broken\"").expect("broken pyproject"); let parse_error = detect_python_project( temp.path(), &pyproject, &mut ecosystems, &mut manifests, &mut command_map, &mut entrypoints, ) .expect_err("broken pyproject should fail"); assert!( parse_error .to_string() .contains("failed to parse pyproject") ); } #[test] fn detect_go_entrypoints_finds_root_and_cmd_main_files() { let temp = tempfile::tempdir().expect("tempdir"); let go_mod = temp.path().join("go.mod"); fs::write(&go_mod, "module example.com/demo\n").expect("go.mod"); fs::create_dir_all(temp.path().join("cmd").join("server")).expect("cmd dir"); fs::create_dir_all(temp.path().join("cmd").join("worker")).expect("cmd dir"); fs::write(temp.path().join("main.go"), "package main\n").expect("main"); fs::write( temp.path().join("cmd").join("server").join("main.go"), "package main\n", ) .expect("cmd main"); let mut entrypoints = Vec::new(); detect_go_entrypoints(temp.path(), &go_mod, &mut entrypoints); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.ecosystem == "go" && entrypoint.kind == "go_main" && entrypoint.path == "main.go" })); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.ecosystem == "go" && entrypoint.kind == "go_cmd" && entrypoint.path.replace('\\', "/") == "cmd/server/main.go" })); assert!( !entrypoints .iter() .any(|entrypoint| entrypoint.path.replace('\\', "/") == "cmd/worker/main.go") ); } #[test] fn detect_scripted_layout_surfaces_manifestless_build_projects() { let temp = tempfile::tempdir().expect("tempdir"); fs::create_dir_all(temp.path().join("src")).expect("src dir"); fs::create_dir_all(temp.path().join("tests")).expect("tests dir"); fs::create_dir_all(temp.path().join("docs")).expect("docs dir"); fs::write(temp.path().join("build.ps1"), "Write-Host build").expect("build script"); fs::write(temp.path().join("README.md"), "# demo").expect("readme"); fs::write(temp.path().join("src").join("Plugin.cs"), "class Plugin {}").expect("source"); fs::write( temp.path().join("tests").join("PluginTests.cs"), "class PluginTests {}", ) .expect("test source"); let files = vec![ temp.path().join("build.ps1"), temp.path().join("README.md"), temp.path().join("src").join("Plugin.cs"), temp.path().join("tests").join("PluginTests.cs"), temp.path().join("docs").join("notes.md"), ]; let mut ecosystems = BTreeSet::new(); let mut manifests = Vec::new(); let mut command_map = BTreeMap::new(); let mut entrypoints = Vec::new(); detect_scripted_layout( temp.path(), &files, &mut ecosystems, &mut manifests, &mut command_map, &mut entrypoints, ); assert!(ecosystems.contains("scripted")); assert!(manifests.iter().any(|manifest| { manifest.ecosystem == "scripted" && manifest.kind == "build_script" && normalize_separators(&manifest.path) == "build.ps1" })); let scripted = command_map.get("scripted").expect("scripted commands"); assert!(scripted.build.contains("pwsh -NoProfile -File build.ps1")); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.ecosystem == "scripted" && entrypoint.kind == "source_dir" && entrypoint.path == "src" })); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.ecosystem == "scripted" && entrypoint.kind == "test_dir" && entrypoint.path == "tests" })); assert!(entrypoints.iter().any(|entrypoint| { entrypoint.ecosystem == "scripted" && entrypoint.kind == "docs_dir" && entrypoint.path == "docs" })); } #[test] fn detect_make_commands_handles_test_and_check_targets() { let temp = tempfile::tempdir().expect("tempdir"); let makefile = temp.path().join("Makefile"); fs::write(&makefile, "build:\n\t@echo build\ncheck:\n\t@echo check\n").expect("makefile"); let mut command_map = BTreeMap::new(); detect_make_commands(&makefile, &mut command_map).expect("make commands"); let hints = command_map.get("make").expect("make hints"); assert!(hints.build.contains("make")); assert!(hints.test.contains("make test")); fs::write(&makefile, "build:\n\t@echo build\n").expect("makefile"); let mut command_map = BTreeMap::new(); detect_make_commands(&makefile, &mut command_map).expect("make commands"); let hints = command_map.get("make").expect("make hints"); assert!(hints.build.contains("make")); assert!(hints.test.is_empty()); } #[test] fn detect_github_actions_filters_yaml_and_reports_directory_errors() { let temp = tempfile::tempdir().expect("tempdir"); let workflows = temp.path().join(".github").join("workflows"); fs::create_dir_all(&workflows).expect("workflows"); fs::write(workflows.join("ci.yml"), "name: ci\n").expect("ci"); fs::write(workflows.join("release.yaml"), "name: release\n").expect("release"); fs::write(workflows.join("notes.txt"), "ignore\n").expect("notes"); let mut ci = Vec::new(); detect_github_actions(temp.path(), &mut ci).expect("github actions"); assert_eq!(ci.len(), 2); assert!( ci.iter() .any(|hint| hint.path.replace('\\', "/") == ".github/workflows/ci.yml") ); assert!( ci.iter() .any(|hint| hint.path.replace('\\', "/") == ".github/workflows/release.yaml") ); let broken = tempfile::tempdir().expect("broken tempdir"); let broken_github = broken.path().join(".github"); fs::create_dir_all(&broken_github).expect("broken github dir"); fs::write(broken_github.join("workflows"), "not a directory").expect("broken workflows"); let mut ci = Vec::new(); let error = detect_github_actions(broken.path(), &mut ci).expect_err("workflows file should fail"); assert!( error .to_string() .contains("failed to read GitHub workflow directory") ); } #[test] fn inspect_repository_detects_mixed_repo_layouts_in_one_pass() { let temp = tempfile::tempdir().expect("tempdir"); fs::create_dir_all(temp.path().join(".github/workflows")).expect("workflow dir"); fs::create_dir_all(temp.path().join("src")).expect("src dir"); fs::create_dir_all(temp.path().join("tests")).expect("tests dir"); fs::create_dir_all(temp.path().join("docs")).expect("docs dir"); fs::create_dir_all(temp.path().join("cmd/demo")).expect("cmd dir"); fs::write( temp.path().join("Cargo.toml"), "[package]\nname='demo'\nversion='0.1.0'\n", ) .expect("cargo"); fs::write( temp.path().join("package.json"), r#"{"packageManager":"pnpm@9.0.0","scripts":{"build":"tsc","test":"vitest"},"main":"index.js"}"#, ) .expect("package"); fs::write(temp.path().join("pnpm-lock.yaml"), "lockfileVersion: '9.0'").expect("pnpm"); fs::write( temp.path().join("pyproject.toml"), "[project]\nname='demo'\n[project.scripts]\nserve='demo:main'\n", ) .expect("pyproject"); fs::write(temp.path().join("go.mod"), "module example.com/demo\n").expect("go"); fs::write(temp.path().join("CMakeLists.txt"), "project(demo)\n").expect("cmake"); fs::write(temp.path().join("Makefile"), "test:\n\tpytest\n").expect("make"); fs::write(temp.path().join("build.ps1"), "Write-Host build\n").expect("build script"); fs::write(temp.path().join("test.sh"), "pytest\n").expect("test script"); fs::write(temp.path().join("requirements.txt"), "pytest\n").expect("requirements"); fs::write( temp.path().join(".github/workflows/ci.yml"), "name: ci\non: [push]\n", ) .expect("workflow"); fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").expect("src"); fs::write( temp.path().join("tests/test_demo.py"), "def test_demo():\n assert True\n", ) .expect("tests"); fs::write(temp.path().join("docs/guide.md"), "# guide\n").expect("docs"); fs::write( temp.path().join("cmd/demo/main.go"), "package main\nfunc main() {}\n", ) .expect("go main"); let summary = inspect_repository(&Cli { common: CommonArgs::default(), max_depth: None, include_hidden: true, path: Some(temp.path().to_path_buf()), }) .expect("summary"); let ecosystems = summary .ecosystems .iter() .map(|ecosystem| ecosystem.name.as_str()) .collect::>(); assert!(ecosystems.contains("cargo")); assert!(ecosystems.contains("pnpm")); assert!(ecosystems.contains("python")); assert!(ecosystems.contains("go")); assert!(ecosystems.contains("cmake")); assert!(ecosystems.contains("make")); assert!(ecosystems.contains("scripted")); assert!( summary .ci .iter() .any(|hint| normalize_separators(&hint.path) == ".github/workflows/ci.yml") ); assert!( summary .entrypoints .iter() .any(|entrypoint| entrypoint.path == "src") ); assert!(summary.commands.iter().any(|command| { command.ecosystem == "cargo" && command.build.contains(&"cargo build".to_string()) })); assert!(summary.commands.iter().any(|command| { command.ecosystem == "scripted" && command .build .iter() .any(|value| value.contains("build.ps1")) })); } #[test] fn dedup_helpers_sort_and_remove_duplicates() { let mut manifests = vec![ Manifest { ecosystem: "python".to_string(), kind: "pyproject_toml".to_string(), path: "pyproject.toml".to_string(), }, Manifest { ecosystem: "cargo".to_string(), kind: "cargo_toml".to_string(), path: "Cargo.toml".to_string(), }, Manifest { ecosystem: "python".to_string(), kind: "pyproject_toml".to_string(), path: "pyproject.toml".to_string(), }, ]; dedup_manifests(&mut manifests); assert_eq!(manifests.len(), 2); assert_eq!(manifests[0].ecosystem, "cargo"); assert_eq!(manifests[1].ecosystem, "python"); let mut entrypoints = vec![ EntryPoint { ecosystem: "go".to_string(), kind: "go_cmd".to_string(), path: "cmd/server/main.go".to_string(), }, EntryPoint { ecosystem: "go".to_string(), kind: "go_main".to_string(), path: "main.go".to_string(), }, EntryPoint { ecosystem: "go".to_string(), kind: "go_cmd".to_string(), path: "cmd/server/main.go".to_string(), }, ]; dedup_entrypoints(&mut entrypoints); assert_eq!(entrypoints.len(), 2); assert_eq!(entrypoints[0].kind, "go_cmd"); assert_eq!(entrypoints[1].kind, "go_main"); let mut ci = vec![ CiHint { provider: "github_actions".to_string(), kind: "workflow".to_string(), path: ".github/workflows/release.yaml".to_string(), }, CiHint { provider: "github_actions".to_string(), kind: "workflow".to_string(), path: ".github/workflows/ci.yml".to_string(), }, CiHint { provider: "github_actions".to_string(), kind: "workflow".to_string(), path: ".github/workflows/ci.yml".to_string(), }, ]; dedup_ci(&mut ci); assert_eq!(ci.len(), 2); assert_eq!(ci[0].path, ".github/workflows/ci.yml"); assert_eq!(ci[1].path, ".github/workflows/release.yaml"); } #[test] fn render_summary_and_pipe_list_cover_empty_and_populated_variants() { let empty = RepoSummary { root: "repo".to_string(), ecosystems: Vec::new(), manifests: Vec::new(), commands: Vec::new(), entrypoints: Vec::new(), ci: Vec::new(), }; let empty_rendered = render_summary(&empty); assert!( empty_rendered .contains("root=repo ecosystems=- manifests=0 commands=0 entrypoints=0 ci=0") ); assert_eq!(render_pipe_list(&[]), "-"); let populated = RepoSummary { root: "repo".to_string(), ecosystems: vec![Ecosystem { name: "cargo".to_string(), }], manifests: vec![Manifest { ecosystem: "cargo".to_string(), kind: "cargo_toml".to_string(), path: "Cargo.toml".to_string(), }], commands: vec![CommandHint { ecosystem: "cargo".to_string(), build: vec!["cargo build".to_string(), "cargo clippy".to_string()], test: Vec::new(), }], entrypoints: vec![EntryPoint { ecosystem: "cargo".to_string(), kind: "rust_bin".to_string(), path: "src/main.rs".to_string(), }], ci: vec![CiHint { provider: "github_actions".to_string(), kind: "workflow".to_string(), path: ".github/workflows/ci.yml".to_string(), }], }; let rendered = render_summary(&populated); assert!(rendered.contains("ecosystems=cargo manifests=1 commands=1 entrypoints=1 ci=1")); assert!(rendered.contains("manifest ecosystem=cargo kind=cargo_toml path=Cargo.toml")); assert!(rendered.contains("command ecosystem=cargo build=cargo build|cargo clippy test=-")); assert!(rendered.contains("entrypoint ecosystem=cargo kind=rust_bin path=src/main.rs")); assert!( rendered .contains("ci provider=github_actions kind=workflow path=.github/workflows/ci.yml") ); } }