1781 lines
60 KiB
Rust
1781 lines
60 KiB
Rust
//! 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 <FORMAT> Structured output format: text, json, toon
|
|
--json Shortcut for --format json
|
|
--toon Shortcut for --format toon
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
--quiet Suppress non-essential status output
|
|
--max-depth <COUNT> 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<usize>,
|
|
include_hidden: bool,
|
|
path: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum ParseOutcome {
|
|
Help,
|
|
Version,
|
|
Run,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
struct RepoSummary {
|
|
root: String,
|
|
ecosystems: Vec<Ecosystem>,
|
|
manifests: Vec<Manifest>,
|
|
commands: Vec<CommandHint>,
|
|
entrypoints: Vec<EntryPoint>,
|
|
ci: Vec<CiHint>,
|
|
}
|
|
|
|
#[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<String>,
|
|
test: Vec<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
test: BTreeSet<String>,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ScriptedLayoutState {
|
|
has_src: bool,
|
|
has_tests: bool,
|
|
has_docs: bool,
|
|
}
|
|
|
|
struct RepoScanState<'a> {
|
|
ecosystems: &'a mut BTreeSet<String>,
|
|
manifests: &'a mut Vec<Manifest>,
|
|
command_map: &'a mut BTreeMap<String, CommandAccumulator>,
|
|
entrypoints: &'a mut Vec<EntryPoint>,
|
|
ci: &'a mut Vec<CiHint>,
|
|
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<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
|
|
where
|
|
I: IntoIterator<Item = T>,
|
|
T: Into<OsString>,
|
|
{
|
|
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<String, CliError> {
|
|
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<usize, CliError> {
|
|
value
|
|
.parse::<usize>()
|
|
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
|
|
}
|
|
|
|
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
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<RepoSummary, CliError> {
|
|
inspect_repository_with_file_cap(cli, MAX_SCANNED_FILES)
|
|
}
|
|
|
|
fn inspect_repository_with_file_cap(
|
|
cli: &Cli,
|
|
max_scanned_files: usize,
|
|
) -> Result<RepoSummary, CliError> {
|
|
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::<String>::new();
|
|
let mut manifests = Vec::<Manifest>::new();
|
|
let mut entrypoints = Vec::<EntryPoint>::new();
|
|
let mut ci = Vec::<CiHint>::new();
|
|
let mut command_map = BTreeMap::<String, CommandAccumulator>::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::<Vec<_>>();
|
|
let commands = command_map
|
|
.into_iter()
|
|
.map(|(ecosystem, hints)| CommandHint {
|
|
ecosystem,
|
|
build: hints.build.into_iter().collect(),
|
|
test: hints.test.into_iter().collect(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
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<String>,
|
|
manifests: &mut Vec<Manifest>,
|
|
command_map: &mut BTreeMap<String, CommandAccumulator>,
|
|
entrypoints: &mut Vec<EntryPoint>,
|
|
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<String>, entrypoints: &mut Vec<EntryPoint>) {
|
|
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<EntryPoint>) {
|
|
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<String>,
|
|
manifests: &mut Vec<Manifest>,
|
|
command_map: &mut BTreeMap<String, CommandAccumulator>,
|
|
entrypoints: &mut Vec<EntryPoint>,
|
|
) -> 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::<Value>(&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<String, CommandAccumulator>,
|
|
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<EntryPoint>,
|
|
) {
|
|
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<String>,
|
|
manifests: &mut Vec<Manifest>,
|
|
command_map: &mut BTreeMap<String, CommandAccumulator>,
|
|
entrypoints: &mut Vec<EntryPoint>,
|
|
) -> 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::<toml::Value>().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<String>,
|
|
manifests: &mut Vec<Manifest>,
|
|
command_map: &mut BTreeMap<String, CommandAccumulator>,
|
|
entrypoints: &mut Vec<EntryPoint>,
|
|
) {
|
|
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<EntryPoint>) {
|
|
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<String, CommandAccumulator>,
|
|
) -> 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<CiHint>) -> 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<String>, ecosystem: &str) {
|
|
ecosystems.insert(ecosystem.to_string());
|
|
}
|
|
|
|
fn add_manifest(manifests: &mut Vec<Manifest>, 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<EntryPoint>, 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<String, CommandAccumulator>,
|
|
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<String, CommandAccumulator>,
|
|
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<Manifest>) {
|
|
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<EntryPoint>) {
|
|
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<CiHint>) {
|
|
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<String, CliError> {
|
|
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<String, CliError> {
|
|
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::<Vec<_>>()
|
|
),
|
|
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::<Value>(
|
|
&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::<BTreeSet<_>>();
|
|
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")
|
|
);
|
|
}
|
|
}
|