Files
MercuryToolbox/crates/reposhape/src/lib.rs
T

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")
);
}
}