//! 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 Structured output format: text, json, toon --json Shortcut for --format json --toon Shortcut for --format toon --input-format Override stdin parsing mode: auto, lines, jsonl --color 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, } #[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, size_bytes: u64, modified_rfc3339: String, family: FileFamily, is_directory: bool, language_hint: Option, container_hint: Option, is_binary: bool, encoding_hint: Option, bom: Option, newline_style: Option, mixed_newlines: Option, line_ending_counts: Option, line_count: Option, blank_lines: Option, longest_line: Option, 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(args: I) -> Result<(ParseOutcome, Cli), CliError> where I: IntoIterator, T: Into, { let mut parser = lexopt::Parser::from_iter(args); let mut cli = Cli { common: CommonArgs::default(), 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 { 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 { 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, 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, CliError> { common::read_existing_stdin_path_records(buffer, input_format, "fileprobe")? .map_or_else(|| Ok(Vec::new()), Ok) } fn inspect_path(path: &Path) -> Result { 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 { path.extension() .and_then(|value| value.to_str()) .map(str::to_ascii_lowercase) } fn detect_container_hint(bytes: &[u8]) -> Option { 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"!\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 { 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 { 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 { 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 { 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::>(); 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 { 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); } }