|
|
|
@@ -0,0 +1,921 @@
|
|
|
|
|
//! The `fileprobe` command classifies files with lightweight heuristics.
|
|
|
|
|
|
|
|
|
|
use std::ffi::OsString;
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::io::{self, Read};
|
|
|
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
use common::{
|
|
|
|
|
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
|
|
|
|
|
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
|
|
|
|
|
should_read_stdin,
|
|
|
|
|
};
|
|
|
|
|
use lexopt::prelude::{Long, Short, Value as ArgValue};
|
|
|
|
|
use serde::Serialize;
|
|
|
|
|
|
|
|
|
|
const HELP: &str = "\
|
|
|
|
|
Probe file type and usefulness heuristics for AI-friendly workflows.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
fileprobe [OPTIONS] [PATH...]
|
|
|
|
|
|
|
|
|
|
Options:
|
|
|
|
|
--format <FORMAT> Structured output format: text, json, toon
|
|
|
|
|
--json Shortcut for --format json
|
|
|
|
|
--toon Shortcut for --format toon
|
|
|
|
|
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
|
|
|
|
|
--color <WHEN> Control ANSI color output: auto, never
|
|
|
|
|
--quiet Suppress non-essential status output
|
|
|
|
|
-h, --help Show this help text
|
|
|
|
|
-V, --version Show the command version
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
fileprobe .\\src\\main.rs
|
|
|
|
|
fileprobe .\\dist\\bundle.min.js --json | ConvertFrom-Json
|
|
|
|
|
fileprobe .\\samples\\*.json --toon
|
|
|
|
|
fd -t f . .\\src | fileprobe --input-format lines --json | ConvertFrom-Json
|
|
|
|
|
fd -t f . .\\samples | fileprobe --input-format lines --toon
|
|
|
|
|
";
|
|
|
|
|
|
|
|
|
|
/// CLI arguments for the `fileprobe` binary.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
struct Cli {
|
|
|
|
|
/// Shared output and stdin policy flags.
|
|
|
|
|
common: CommonArgs,
|
|
|
|
|
/// Explicit files to inspect when stdin is empty.
|
|
|
|
|
paths: Vec<PathBuf>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
enum ParseOutcome {
|
|
|
|
|
Help,
|
|
|
|
|
Version,
|
|
|
|
|
Run,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
enum FileFamily {
|
|
|
|
|
Directory,
|
|
|
|
|
Source,
|
|
|
|
|
Config,
|
|
|
|
|
Data,
|
|
|
|
|
Text,
|
|
|
|
|
Binary,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct TextStats {
|
|
|
|
|
line_count: usize,
|
|
|
|
|
blank_lines: usize,
|
|
|
|
|
longest_line: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct LineEndingCounts {
|
|
|
|
|
lf: usize,
|
|
|
|
|
crlf: usize,
|
|
|
|
|
cr: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(
|
|
|
|
|
clippy::struct_excessive_bools,
|
|
|
|
|
reason = "the JSON contract intentionally exposes fixed heuristic toggles as stable booleans"
|
|
|
|
|
)]
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
|
|
|
struct FileReport {
|
|
|
|
|
path: String,
|
|
|
|
|
extension: Option<String>,
|
|
|
|
|
size_bytes: u64,
|
|
|
|
|
modified_rfc3339: String,
|
|
|
|
|
family: FileFamily,
|
|
|
|
|
is_directory: bool,
|
|
|
|
|
language_hint: Option<String>,
|
|
|
|
|
container_hint: Option<String>,
|
|
|
|
|
is_binary: bool,
|
|
|
|
|
encoding_hint: Option<String>,
|
|
|
|
|
bom: Option<String>,
|
|
|
|
|
newline_style: Option<String>,
|
|
|
|
|
mixed_newlines: Option<bool>,
|
|
|
|
|
line_ending_counts: Option<LineEndingCounts>,
|
|
|
|
|
line_count: Option<usize>,
|
|
|
|
|
blank_lines: Option<usize>,
|
|
|
|
|
longest_line: Option<usize>,
|
|
|
|
|
likely_generated: bool,
|
|
|
|
|
likely_minified: bool,
|
|
|
|
|
likely_lockfile: bool,
|
|
|
|
|
likely_test: bool,
|
|
|
|
|
likely_vendor: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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!("fileprobe {}", 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(),
|
|
|
|
|
paths: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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("input-format") => {
|
|
|
|
|
let value = parser_value_string(&mut parser, "--input-format")?;
|
|
|
|
|
cli.common.input_format = parse_input_format(&value)?;
|
|
|
|
|
}
|
|
|
|
|
Long("color") => {
|
|
|
|
|
let value = parser_value_string(&mut parser, "--color")?;
|
|
|
|
|
cli.common.color = parse_color_choice(&value)?;
|
|
|
|
|
}
|
|
|
|
|
Long("quiet") => cli.common.quiet = true,
|
|
|
|
|
ArgValue(path) => cli.paths.push(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 run(cli: &Cli) -> Result<ExitCode, CliError> {
|
|
|
|
|
let paths = collect_paths(cli)?;
|
|
|
|
|
if paths.is_empty() {
|
|
|
|
|
return Err(CliError::usage(
|
|
|
|
|
"provide at least one path or pipe paths into stdin",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut reports = Vec::new();
|
|
|
|
|
for path in &paths {
|
|
|
|
|
reports.push(inspect_path(path)?);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match cli.common.render_mode() {
|
|
|
|
|
RenderMode::Json => print_json(&reports)?,
|
|
|
|
|
RenderMode::Toon => print_structured(&reports, RenderMode::Toon)?,
|
|
|
|
|
RenderMode::Text => {
|
|
|
|
|
for report in &reports {
|
|
|
|
|
println!("{}", render_text_report(report));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(map_result_count(reports.len()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_paths(cli: &Cli) -> Result<Vec<PathBuf>, CliError> {
|
|
|
|
|
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
|
|
|
|
|
let mut buffer = String::new();
|
|
|
|
|
io::stdin()
|
|
|
|
|
.read_to_string(&mut buffer)
|
|
|
|
|
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
|
|
|
|
|
let paths = parse_paths_from_string(&buffer, cli.common.input_format)?;
|
|
|
|
|
if !paths.is_empty() {
|
|
|
|
|
return Ok(paths);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
common::expand_input_patterns(&cli.paths, "fileprobe")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_paths_from_string(
|
|
|
|
|
buffer: &str,
|
|
|
|
|
input_format: InputFormat,
|
|
|
|
|
) -> Result<Vec<PathBuf>, CliError> {
|
|
|
|
|
common::read_existing_stdin_path_records(buffer, input_format, "fileprobe")?
|
|
|
|
|
.map_or_else(|| Ok(Vec::new()), Ok)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn inspect_path(path: &Path) -> Result<FileReport, CliError> {
|
|
|
|
|
let path_text = path.display().to_string();
|
|
|
|
|
let metadata = fs::metadata(path).map_err(|error| {
|
|
|
|
|
CliError::runtime(format!("failed to read metadata for {path_text}: {error}"))
|
|
|
|
|
})?;
|
|
|
|
|
let modified = metadata.modified().map_err(|error| {
|
|
|
|
|
CliError::runtime(format!(
|
|
|
|
|
"failed to read modified time for {path_text}: {error}"
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
if metadata.is_dir() {
|
|
|
|
|
return Ok(FileReport {
|
|
|
|
|
path: path_text,
|
|
|
|
|
extension: None,
|
|
|
|
|
size_bytes: 0,
|
|
|
|
|
modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(),
|
|
|
|
|
family: FileFamily::Directory,
|
|
|
|
|
is_directory: true,
|
|
|
|
|
language_hint: None,
|
|
|
|
|
container_hint: Some("directory".to_string()),
|
|
|
|
|
is_binary: false,
|
|
|
|
|
encoding_hint: None,
|
|
|
|
|
bom: None,
|
|
|
|
|
newline_style: None,
|
|
|
|
|
mixed_newlines: None,
|
|
|
|
|
line_ending_counts: None,
|
|
|
|
|
line_count: None,
|
|
|
|
|
blank_lines: None,
|
|
|
|
|
longest_line: None,
|
|
|
|
|
likely_generated: false,
|
|
|
|
|
likely_minified: false,
|
|
|
|
|
likely_lockfile: false,
|
|
|
|
|
likely_test: detect_test_path(path),
|
|
|
|
|
likely_vendor: detect_vendor_path(path),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
let bytes = fs::read(path)
|
|
|
|
|
.map_err(|error| CliError::runtime(format!("failed to read {path_text}: {error}")))?;
|
|
|
|
|
|
|
|
|
|
let extension = extension_label(path);
|
|
|
|
|
let container_hint = detect_container_hint(&bytes);
|
|
|
|
|
let bom = detect_bom(&bytes);
|
|
|
|
|
let is_binary = is_binary_blob(&bytes, container_hint.as_deref(), bom.as_deref());
|
|
|
|
|
let language_hint = detect_language_hint(path);
|
|
|
|
|
let family = classify_family(is_binary, language_hint.as_deref());
|
|
|
|
|
let decoded_text = (!is_binary)
|
|
|
|
|
.then(|| decode_text(&bytes, bom.as_deref()))
|
|
|
|
|
.flatten();
|
|
|
|
|
let text_stats = decoded_text.as_deref().map(summarize_text);
|
|
|
|
|
let encoding_hint = decoded_text
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|_| detect_encoding_hint(&bytes, bom.as_deref()));
|
|
|
|
|
let line_ending_counts = decoded_text.as_deref().map(count_line_endings);
|
|
|
|
|
let newline_style = line_ending_counts.as_ref().and_then(detect_newline_style);
|
|
|
|
|
let mixed_newlines = line_ending_counts
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|counts| distinct_line_endings(counts) > 1);
|
|
|
|
|
let likely_lockfile = detect_lockfile(path);
|
|
|
|
|
let likely_generated = detect_generated(path, &bytes, likely_lockfile);
|
|
|
|
|
let likely_minified = text_stats
|
|
|
|
|
.as_ref()
|
|
|
|
|
.zip(decoded_text.as_deref())
|
|
|
|
|
.is_some_and(|(stats, text)| detect_minified(text, stats));
|
|
|
|
|
|
|
|
|
|
Ok(FileReport {
|
|
|
|
|
path: path_text,
|
|
|
|
|
extension,
|
|
|
|
|
size_bytes: metadata.len(),
|
|
|
|
|
modified_rfc3339: humantime::format_rfc3339_seconds(modified).to_string(),
|
|
|
|
|
family,
|
|
|
|
|
is_directory: false,
|
|
|
|
|
language_hint,
|
|
|
|
|
container_hint,
|
|
|
|
|
is_binary,
|
|
|
|
|
encoding_hint,
|
|
|
|
|
bom,
|
|
|
|
|
newline_style,
|
|
|
|
|
mixed_newlines,
|
|
|
|
|
line_ending_counts,
|
|
|
|
|
line_count: text_stats.as_ref().map(|stats| stats.line_count),
|
|
|
|
|
blank_lines: text_stats.as_ref().map(|stats| stats.blank_lines),
|
|
|
|
|
longest_line: text_stats.as_ref().map(|stats| stats.longest_line),
|
|
|
|
|
likely_generated,
|
|
|
|
|
likely_minified,
|
|
|
|
|
likely_lockfile,
|
|
|
|
|
likely_test: detect_test_path(path),
|
|
|
|
|
likely_vendor: detect_vendor_path(path),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn extension_label(path: &Path) -> Option<String> {
|
|
|
|
|
path.extension()
|
|
|
|
|
.and_then(|value| value.to_str())
|
|
|
|
|
.map(str::to_ascii_lowercase)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_container_hint(bytes: &[u8]) -> Option<String> {
|
|
|
|
|
magic_container_hint(bytes).map(str::to_owned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn magic_container_hint(bytes: &[u8]) -> Option<&'static str> {
|
|
|
|
|
if bytes.starts_with(b"MZ") || bytes.windows(2).take(8).any(|window| window == b"MZ") {
|
|
|
|
|
Some("pe")
|
|
|
|
|
} else if bytes.starts_with(&[0x7f, b'E', b'L', b'F']) {
|
|
|
|
|
Some("elf")
|
|
|
|
|
} else if bytes.starts_with(&[0xfe, 0xed, 0xfa, 0xce])
|
|
|
|
|
|| bytes.starts_with(&[0xfe, 0xed, 0xfa, 0xcf])
|
|
|
|
|
|| bytes.starts_with(&[0xce, 0xfa, 0xed, 0xfe])
|
|
|
|
|
|| bytes.starts_with(&[0xcf, 0xfa, 0xed, 0xfe])
|
|
|
|
|
|| bytes.starts_with(&[0xca, 0xfe, 0xba, 0xbe])
|
|
|
|
|
{
|
|
|
|
|
Some("mach")
|
|
|
|
|
} else if bytes.starts_with(b"!<arch>\n") {
|
|
|
|
|
Some("archive")
|
|
|
|
|
} else if bytes.starts_with(b"PK\x03\x04") {
|
|
|
|
|
Some("zip")
|
|
|
|
|
} else if bytes.starts_with(b"SQLite format 3\0") {
|
|
|
|
|
Some("sqlite")
|
|
|
|
|
} else if bytes.starts_with(b"%PDF-") {
|
|
|
|
|
Some("pdf")
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_binary_blob(bytes: &[u8], container_hint: Option<&str>, bom: Option<&str>) -> bool {
|
|
|
|
|
if container_hint.is_some() {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if bom.is_some() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if bytes.contains(&0) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
std::str::from_utf8(bytes).is_err()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_bom(bytes: &[u8]) -> Option<String> {
|
|
|
|
|
if bytes.starts_with(&[0xef, 0xbb, 0xbf]) {
|
|
|
|
|
Some("utf-8".to_owned())
|
|
|
|
|
} else if bytes.starts_with(&[0xff, 0xfe]) {
|
|
|
|
|
Some("utf-16le".to_owned())
|
|
|
|
|
} else if bytes.starts_with(&[0xfe, 0xff]) {
|
|
|
|
|
Some("utf-16be".to_owned())
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_encoding_hint(bytes: &[u8], bom: Option<&str>) -> Option<String> {
|
|
|
|
|
if let Some(bom) = bom {
|
|
|
|
|
return Some(bom.to_string());
|
|
|
|
|
}
|
|
|
|
|
std::str::from_utf8(bytes).ok().map(|_| "utf-8".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_language_hint(path: &Path) -> Option<String> {
|
|
|
|
|
match path.extension().and_then(|value| value.to_str()) {
|
|
|
|
|
Some("rs") => Some("rust".to_string()),
|
|
|
|
|
Some("cs") => Some("csharp".to_string()),
|
|
|
|
|
Some("js" | "mjs" | "cjs") => Some("javascript".to_string()),
|
|
|
|
|
Some("ts" | "tsx") => Some("typescript".to_string()),
|
|
|
|
|
Some("json") => Some("json".to_string()),
|
|
|
|
|
Some("jsonl") => Some("jsonl".to_string()),
|
|
|
|
|
Some("csv") => Some("csv".to_string()),
|
|
|
|
|
Some("tsv") => Some("tsv".to_string()),
|
|
|
|
|
Some("toml") => Some("toml".to_string()),
|
|
|
|
|
Some("yaml" | "yml") => Some("yaml".to_string()),
|
|
|
|
|
Some("ps1") => Some("powershell".to_string()),
|
|
|
|
|
Some("py") => Some("python".to_string()),
|
|
|
|
|
Some("md") => Some("markdown".to_string()),
|
|
|
|
|
Some("xml") => Some("xml".to_string()),
|
|
|
|
|
Some("html" | "htm") => Some("html".to_string()),
|
|
|
|
|
Some("css") => Some("css".to_string()),
|
|
|
|
|
Some("lock") => Some("lockfile".to_string()),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn classify_family(is_binary: bool, language_hint: Option<&str>) -> FileFamily {
|
|
|
|
|
if is_binary {
|
|
|
|
|
return FileFamily::Binary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match language_hint {
|
|
|
|
|
Some(
|
|
|
|
|
"rust" | "csharp" | "javascript" | "typescript" | "powershell" | "python" | "xml"
|
|
|
|
|
| "html" | "css",
|
|
|
|
|
) => FileFamily::Source,
|
|
|
|
|
Some("json" | "toml" | "yaml" | "lockfile") => FileFamily::Config,
|
|
|
|
|
Some("jsonl" | "csv" | "tsv") => FileFamily::Data,
|
|
|
|
|
Some(_) | None => FileFamily::Text,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_text(bytes: &[u8], bom: Option<&str>) -> Option<String> {
|
|
|
|
|
match bom {
|
|
|
|
|
Some("utf-8") => Some(String::from_utf8_lossy(&bytes[3..]).to_string()),
|
|
|
|
|
Some("utf-16le") => Some(decode_utf16(&bytes[2..], true)),
|
|
|
|
|
Some("utf-16be") => Some(decode_utf16(&bytes[2..], false)),
|
|
|
|
|
Some(_) => None,
|
|
|
|
|
None => Some(String::from_utf8_lossy(bytes).to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_utf16(bytes: &[u8], little_endian: bool) -> String {
|
|
|
|
|
let units = bytes
|
|
|
|
|
.chunks_exact(2)
|
|
|
|
|
.map(|chunk| {
|
|
|
|
|
if little_endian {
|
|
|
|
|
u16::from_le_bytes([chunk[0], chunk[1]])
|
|
|
|
|
} else {
|
|
|
|
|
u16::from_be_bytes([chunk[0], chunk[1]])
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
String::from_utf16_lossy(&units)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn summarize_text(text: &str) -> TextStats {
|
|
|
|
|
let mut line_count = 0_usize;
|
|
|
|
|
let mut blank_lines = 0_usize;
|
|
|
|
|
let mut longest_line = 0_usize;
|
|
|
|
|
|
|
|
|
|
for line in text.lines() {
|
|
|
|
|
line_count += 1;
|
|
|
|
|
if line.trim().is_empty() {
|
|
|
|
|
blank_lines += 1;
|
|
|
|
|
}
|
|
|
|
|
longest_line = longest_line.max(line.len());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TextStats {
|
|
|
|
|
line_count,
|
|
|
|
|
blank_lines,
|
|
|
|
|
longest_line,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_generated(path: &Path, bytes: &[u8], likely_lockfile: bool) -> bool {
|
|
|
|
|
if likely_lockfile {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let file_name = file_name_lower(path);
|
|
|
|
|
if is_known_generated_artifact(path, &file_name) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
if file_name.contains(".designer.") || file_name.contains(".generated.") {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contains_generated_marker(&bytes[..bytes.len().min(512)])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_known_generated_artifact(path: &Path, file_name: &str) -> bool {
|
|
|
|
|
if matches!(
|
|
|
|
|
file_name,
|
|
|
|
|
"project.assets.json"
|
|
|
|
|
| "project.nuget.cache"
|
|
|
|
|
| ".netcoreapp,version=v1.0.assemblyattributes.cs"
|
|
|
|
|
| ".netframework,version=v4.8.assemblyattributes.cs"
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let in_obj_dir = path.components().any(|component| match component {
|
|
|
|
|
Component::Normal(value) => value.to_string_lossy().eq_ignore_ascii_case("obj"),
|
|
|
|
|
_ => false,
|
|
|
|
|
});
|
|
|
|
|
in_obj_dir
|
|
|
|
|
&& (file_name.ends_with(".assemblyinfo.cs")
|
|
|
|
|
|| file_name.ends_with(".assemblyattributes.cs"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn contains_generated_marker(preview: &[u8]) -> bool {
|
|
|
|
|
const MARKERS: [&[u8]; 4] = [
|
|
|
|
|
b"@generated",
|
|
|
|
|
b"generated by",
|
|
|
|
|
b"auto-generated",
|
|
|
|
|
b"automatically generated",
|
|
|
|
|
];
|
|
|
|
|
MARKERS
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|marker| contains_ascii_case_insensitive(preview, marker))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool {
|
|
|
|
|
haystack
|
|
|
|
|
.windows(needle.len())
|
|
|
|
|
.any(|window| window.eq_ignore_ascii_case(needle))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn count_line_endings(text: &str) -> LineEndingCounts {
|
|
|
|
|
let mut counts = LineEndingCounts {
|
|
|
|
|
lf: 0,
|
|
|
|
|
crlf: 0,
|
|
|
|
|
cr: 0,
|
|
|
|
|
};
|
|
|
|
|
let bytes = text.as_bytes();
|
|
|
|
|
let mut index = 0_usize;
|
|
|
|
|
while index < bytes.len() {
|
|
|
|
|
match bytes[index] {
|
|
|
|
|
b'\r' if bytes.get(index + 1) == Some(&b'\n') => {
|
|
|
|
|
counts.crlf += 1;
|
|
|
|
|
index += 2;
|
|
|
|
|
}
|
|
|
|
|
b'\r' => {
|
|
|
|
|
counts.cr += 1;
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
b'\n' => {
|
|
|
|
|
counts.lf += 1;
|
|
|
|
|
index += 1;
|
|
|
|
|
}
|
|
|
|
|
_ => index += 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
counts
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_newline_style(counts: &LineEndingCounts) -> Option<String> {
|
|
|
|
|
if counts.crlf > 0 && counts.lf == 0 && counts.cr == 0 {
|
|
|
|
|
Some("crlf".to_owned())
|
|
|
|
|
} else if counts.lf > 0 && counts.crlf == 0 && counts.cr == 0 {
|
|
|
|
|
Some("lf".to_owned())
|
|
|
|
|
} else if counts.cr > 0 && counts.lf == 0 && counts.crlf == 0 {
|
|
|
|
|
Some("cr".to_owned())
|
|
|
|
|
} else if distinct_line_endings(counts) > 1 {
|
|
|
|
|
Some("mixed".to_owned())
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn distinct_line_endings(counts: &LineEndingCounts) -> usize {
|
|
|
|
|
usize::from(counts.lf > 0) + usize::from(counts.crlf > 0) + usize::from(counts.cr > 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_minified(text: &str, stats: &TextStats) -> bool {
|
|
|
|
|
if stats.line_count == 0 || stats.line_count > 3 || stats.longest_line < 80 {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut non_newline_len = 0usize;
|
|
|
|
|
let mut whitespace_chars = 0usize;
|
|
|
|
|
let mut has_open_brace = false;
|
|
|
|
|
let mut has_semicolon = false;
|
|
|
|
|
|
|
|
|
|
for ch in text.chars() {
|
|
|
|
|
if matches!(ch, '\n' | '\r') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
non_newline_len += 1;
|
|
|
|
|
if ch.is_whitespace() {
|
|
|
|
|
whitespace_chars += 1;
|
|
|
|
|
}
|
|
|
|
|
has_open_brace |= ch == '{';
|
|
|
|
|
has_semicolon |= ch == ';';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if non_newline_len == 0 {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
whitespace_chars.saturating_mul(100) < non_newline_len.saturating_mul(12)
|
|
|
|
|
&& has_open_brace
|
|
|
|
|
&& has_semicolon
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_lockfile(path: &Path) -> bool {
|
|
|
|
|
let file_name = file_name_lower(path);
|
|
|
|
|
Path::new(&file_name)
|
|
|
|
|
.extension()
|
|
|
|
|
.is_some_and(|extension| extension.eq_ignore_ascii_case("lock"))
|
|
|
|
|
|| matches!(
|
|
|
|
|
file_name.as_str(),
|
|
|
|
|
"package-lock.json"
|
|
|
|
|
| "pnpm-lock.yaml"
|
|
|
|
|
| "yarn.lock"
|
|
|
|
|
| "bun.lockb"
|
|
|
|
|
| "composer.lock"
|
|
|
|
|
| "poetry.lock"
|
|
|
|
|
| "uv.lock"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_test_path(path: &Path) -> bool {
|
|
|
|
|
let lower_path = path.display().to_string().to_ascii_lowercase();
|
|
|
|
|
lower_path.contains("\\tests\\")
|
|
|
|
|
|| lower_path.contains("/tests/")
|
|
|
|
|
|| lower_path.contains(".test.")
|
|
|
|
|
|| lower_path.contains("_test.")
|
|
|
|
|
|| lower_path.contains(".spec.")
|
|
|
|
|
|| lower_path.contains("_spec.")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn detect_vendor_path(path: &Path) -> bool {
|
|
|
|
|
path.components().any(|component| {
|
|
|
|
|
let Component::Normal(segment) = component else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
let lower = segment.to_string_lossy().to_ascii_lowercase();
|
|
|
|
|
matches!(
|
|
|
|
|
lower.as_str(),
|
|
|
|
|
"vendor" | "node_modules" | "third_party" | "packages"
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn file_name_lower(path: &Path) -> String {
|
|
|
|
|
path.file_name()
|
|
|
|
|
.and_then(|value| value.to_str())
|
|
|
|
|
.map(str::to_ascii_lowercase)
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_text_report(report: &FileReport) -> String {
|
|
|
|
|
let mut line = format!(
|
|
|
|
|
"path={} family={} language={} binary={} size={} mtime={}",
|
|
|
|
|
report.path,
|
|
|
|
|
family_label(report.family),
|
|
|
|
|
report.language_hint.as_deref().unwrap_or("-"),
|
|
|
|
|
report.is_binary,
|
|
|
|
|
report.size_bytes,
|
|
|
|
|
report.modified_rfc3339
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if let Some(extension) = &report.extension {
|
|
|
|
|
write!(line, " ext={extension}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
write!(line, " directory={}", report.is_directory).expect("writing to a String cannot fail");
|
|
|
|
|
if let Some(container_hint) = &report.container_hint {
|
|
|
|
|
write!(line, " container={container_hint}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(encoding_hint) = &report.encoding_hint {
|
|
|
|
|
write!(line, " encoding={encoding_hint}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(bom) = &report.bom {
|
|
|
|
|
write!(line, " bom={bom}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(newline_style) = &report.newline_style {
|
|
|
|
|
write!(line, " newline={newline_style}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(mixed_newlines) = report.mixed_newlines {
|
|
|
|
|
write!(line, " mixed_newlines={mixed_newlines}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(line_count) = report.line_count {
|
|
|
|
|
write!(line, " lines={line_count}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(blank_lines) = report.blank_lines {
|
|
|
|
|
write!(line, " blank={blank_lines}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
if let Some(longest_line) = report.longest_line {
|
|
|
|
|
write!(line, " longest={longest_line}").expect("writing to a String cannot fail");
|
|
|
|
|
}
|
|
|
|
|
write!(
|
|
|
|
|
line,
|
|
|
|
|
" generated={} minified={} lockfile={} test={} vendor={}",
|
|
|
|
|
report.likely_generated,
|
|
|
|
|
report.likely_minified,
|
|
|
|
|
report.likely_lockfile,
|
|
|
|
|
report.likely_test,
|
|
|
|
|
report.likely_vendor
|
|
|
|
|
)
|
|
|
|
|
.expect("writing to a String cannot fail");
|
|
|
|
|
|
|
|
|
|
line
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const fn family_label(family: FileFamily) -> &'static str {
|
|
|
|
|
match family {
|
|
|
|
|
FileFamily::Directory => "directory",
|
|
|
|
|
FileFamily::Source => "source",
|
|
|
|
|
FileFamily::Config => "config",
|
|
|
|
|
FileFamily::Data => "data",
|
|
|
|
|
FileFamily::Text => "text",
|
|
|
|
|
FileFamily::Binary => "binary",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use std::fs;
|
|
|
|
|
|
|
|
|
|
use common::ColorChoice;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
|
|
|
|
|
CommonArgs {
|
|
|
|
|
json,
|
|
|
|
|
format: None,
|
|
|
|
|
input_format,
|
|
|
|
|
color: ColorChoice::Never,
|
|
|
|
|
quiet: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_paths_supports_line_and_json_inputs() {
|
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
|
let line_path = temp.path().join("a.rs");
|
|
|
|
|
let jsonl_one = temp.path().join("b.cs");
|
|
|
|
|
let jsonl_two = temp.path().join("c.js");
|
|
|
|
|
let auto_path = temp.path().join("d.toml");
|
|
|
|
|
fs::write(&line_path, "a").expect("line file");
|
|
|
|
|
fs::write(&jsonl_one, "b").expect("jsonl one");
|
|
|
|
|
fs::write(&jsonl_two, "c").expect("jsonl two");
|
|
|
|
|
fs::write(&auto_path, "d").expect("auto file");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_paths_from_string(&format!("{}\n", line_path.display()), InputFormat::Lines)
|
|
|
|
|
.expect("lines"),
|
|
|
|
|
vec![line_path]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_paths_from_string(
|
|
|
|
|
&format!(
|
|
|
|
|
"{}\n{{\"path\":{}}}\n",
|
|
|
|
|
serde_json::to_string(&jsonl_one.display().to_string()).expect("jsonl one"),
|
|
|
|
|
serde_json::to_string(&jsonl_two.display().to_string()).expect("jsonl two"),
|
|
|
|
|
),
|
|
|
|
|
InputFormat::Jsonl,
|
|
|
|
|
)
|
|
|
|
|
.expect("jsonl"),
|
|
|
|
|
vec![jsonl_one, jsonl_two]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_paths_from_string(&format!("{}\n", auto_path.display()), InputFormat::Auto)
|
|
|
|
|
.expect("auto"),
|
|
|
|
|
vec![auto_path]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_paths_reports_invalid_jsonl() {
|
|
|
|
|
let error =
|
|
|
|
|
parse_paths_from_string("nope\n", InputFormat::Jsonl).expect_err("invalid jsonl");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
CliError::Usage(message)
|
|
|
|
|
if message.contains("stdin JSONL path line 1 is not valid JSON")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn container_language_and_path_heuristics_are_stable() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
detect_container_hint(b"MZ\x00\x01payload"),
|
|
|
|
|
Some("pe".to_string())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(detect_container_hint(b"%PDF-1.7"), Some("pdf".to_string()));
|
|
|
|
|
assert!(is_binary_blob(b"\x00\x01\xff", None, None));
|
|
|
|
|
assert!(!is_binary_blob(b"plain text", None, None));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
detect_language_hint(Path::new("demo.rs")),
|
|
|
|
|
Some("rust".to_string())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
detect_language_hint(Path::new("events.jsonl")),
|
|
|
|
|
Some("jsonl".to_string())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
classify_family(false, Some("javascript")),
|
|
|
|
|
FileFamily::Source
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(family_label(FileFamily::Directory), "directory");
|
|
|
|
|
assert_eq!(classify_family(false, Some("toml")), FileFamily::Config);
|
|
|
|
|
assert_eq!(classify_family(false, Some("jsonl")), FileFamily::Data);
|
|
|
|
|
assert_eq!(classify_family(true, Some("rust")), FileFamily::Binary);
|
|
|
|
|
assert!(detect_lockfile(Path::new("Cargo.lock")));
|
|
|
|
|
assert!(detect_test_path(Path::new("C:\\repo\\tests\\probe.rs")));
|
|
|
|
|
assert!(detect_vendor_path(Path::new("C:\\repo\\vendor\\lib.rs")));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn generated_and_minified_heuristics_use_content_markers() {
|
|
|
|
|
let stats = summarize_text(
|
|
|
|
|
"function boot(){const state={ready:true,mode:\"fast\"};if(state.ready){console.log(state.mode);}}\n",
|
|
|
|
|
);
|
|
|
|
|
assert!(detect_minified(
|
|
|
|
|
"function boot(){const state={ready:true,mode:\"fast\"};if(state.ready){console.log(state.mode);}}\n",
|
|
|
|
|
&stats,
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
assert!(detect_generated(
|
|
|
|
|
Path::new("generated.lock"),
|
|
|
|
|
b"# This file is automatically @generated by the build system.\n",
|
|
|
|
|
true,
|
|
|
|
|
));
|
|
|
|
|
assert!(detect_generated(
|
|
|
|
|
Path::new("demo.rs"),
|
|
|
|
|
b"// auto-generated by tool\nfn run() {}\n",
|
|
|
|
|
false,
|
|
|
|
|
));
|
|
|
|
|
assert!(detect_generated(
|
|
|
|
|
Path::new("C:\\repo\\obj\\project.assets.json"),
|
|
|
|
|
b"{\"version\":3}\n",
|
|
|
|
|
false,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn inspect_path_and_render_text_cover_text_and_binary_reports() {
|
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
|
let source_path = temp.path().join("sample.rs");
|
|
|
|
|
let binary_path = temp.path().join("sample.bin");
|
|
|
|
|
fs::write(&source_path, b"pub fn run() {}\n").expect("source fixture");
|
|
|
|
|
fs::write(&binary_path, b"MZ\x00\x01\xff").expect("binary fixture");
|
|
|
|
|
|
|
|
|
|
let source_report = inspect_path(&source_path).expect("source report");
|
|
|
|
|
assert_eq!(source_report.family, FileFamily::Source);
|
|
|
|
|
assert_eq!(source_report.language_hint.as_deref(), Some("rust"));
|
|
|
|
|
assert_eq!(source_report.line_count, Some(1));
|
|
|
|
|
assert_eq!(source_report.encoding_hint.as_deref(), Some("utf-8"));
|
|
|
|
|
assert!(render_text_report(&source_report).contains("family=source"));
|
|
|
|
|
|
|
|
|
|
let binary_report = inspect_path(&binary_path).expect("binary report");
|
|
|
|
|
assert_eq!(binary_report.family, FileFamily::Binary);
|
|
|
|
|
assert_eq!(binary_report.container_hint.as_deref(), Some("pe"));
|
|
|
|
|
assert!(binary_report.is_binary);
|
|
|
|
|
assert!(render_text_report(&binary_report).contains("container=pe"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn inspect_path_reports_directories_without_raw_os_errors() {
|
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
|
let report = inspect_path(temp.path()).expect("directory report");
|
|
|
|
|
assert_eq!(report.family, FileFamily::Directory);
|
|
|
|
|
assert!(report.is_directory);
|
|
|
|
|
assert_eq!(report.container_hint.as_deref(), Some("directory"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn detects_bom_and_mixed_newlines() {
|
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
|
let path = temp.path().join("bom.txt");
|
|
|
|
|
fs::write(&path, [0xef, 0xbb, 0xbf, b'a', b'\r', b'\n', b'b', b'\n']).expect("fixture");
|
|
|
|
|
|
|
|
|
|
let report = inspect_path(&path).expect("report");
|
|
|
|
|
|
|
|
|
|
assert_eq!(report.bom.as_deref(), Some("utf-8"));
|
|
|
|
|
assert_eq!(report.newline_style.as_deref(), Some("mixed"));
|
|
|
|
|
assert_eq!(report.mixed_newlines, Some(true));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
report.line_ending_counts,
|
|
|
|
|
Some(LineEndingCounts {
|
|
|
|
|
lf: 1,
|
|
|
|
|
crlf: 1,
|
|
|
|
|
cr: 0,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_accepts_explicit_paths_for_text_and_json_output() {
|
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
|
let path = temp.path().join("sample.cs");
|
|
|
|
|
fs::write(&path, b"class Demo {}\n").expect("fixture");
|
|
|
|
|
|
|
|
|
|
let text_exit = run(&Cli {
|
|
|
|
|
common: common_args(false, InputFormat::Auto),
|
|
|
|
|
paths: vec![path.clone()],
|
|
|
|
|
})
|
|
|
|
|
.expect("text run");
|
|
|
|
|
assert_eq!(text_exit, ExitCode::Success);
|
|
|
|
|
|
|
|
|
|
let json_exit = run(&Cli {
|
|
|
|
|
common: common_args(true, InputFormat::Lines),
|
|
|
|
|
paths: vec![path],
|
|
|
|
|
})
|
|
|
|
|
.expect("json run");
|
|
|
|
|
assert_eq!(json_exit, ExitCode::Success);
|
|
|
|
|
}
|
|
|
|
|
}
|