1use std::fs::File;
4use std::io::Read;
5use std::num::NonZeroUsize;
6use std::sync::atomic::{AtomicUsize, Ordering};
7use std::sync::{Arc, mpsc};
8
9use crate::Index;
10use crate::classify::{ContentFamily, TypeRegistry, classify_with};
11
12use super::{
13 AnalysisApplyOutcome, AnalysisCandidate, AnalysisObservation, AnalysisRequest, AnalyzerOutcome,
14 BasicAccumulator, BasicMetrics, CodeAccumulator, CodeMetrics, ContentProvenance,
15 CoverageReason, FileAnalysis, TextAdmission, WordMetrics,
16 content_markdown_metrics::analyze_markdown,
17};
18
19const READ_CHUNK_BYTES: usize = 64 * 1024;
20const CLASSIFICATION_PREFIX_BYTES: usize = 16 * 1024;
21const MAX_ERROR_BYTES: usize = 512;
22
23#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
25pub struct AnalysisReport {
26 pub candidates: u64,
28 pub bytes_read: u64,
30 pub elapsed_ns: u64,
32 pub applied: u64,
34 pub stale: u64,
36 pub lines: AnalyzerCoverage,
38 pub code: Option<AnalyzerCoverage>,
40 pub words: Option<AnalyzerCoverage>,
42}
43
44#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
46pub struct AnalyzerCoverage {
47 pub analyzed: u64,
49 pub binary: u64,
51 pub invalid_utf8: u64,
53 pub unsupported_encoding: u64,
55 pub changed_during_read: u64,
57 pub io_errors: u64,
59 pub unsupported: u64,
61}
62
63impl AnalysisReport {
64 pub fn is_complete(&self) -> bool {
69 self.stale == 0
70 && self.lines.is_complete()
71 && self.code.is_none_or(AnalyzerCoverage::is_complete)
72 && self.words.is_none_or(AnalyzerCoverage::is_complete)
73 }
74
75 pub fn failure_message(&self) -> Option<String> {
77 (!self.is_complete()).then(|| {
78 format!(
79 "content analysis had operational failures (I/O errors: {}; changed during read: {}; stale results: {}). File and byte totals remain complete; content metrics omit affected files",
80 self.io_errors(), self.changed_during_read(), self.stale
81 )
82 })
83 }
84
85 fn io_errors(&self) -> u64 {
86 self.lines.io_errors
87 }
88
89 fn changed_during_read(&self) -> u64 {
90 self.lines.changed_during_read
91 }
92}
93
94impl AnalyzerCoverage {
95 const fn is_complete(self) -> bool {
96 self.changed_during_read == 0 && self.io_errors == 0
97 }
98
99 fn count(&mut self, reason: CoverageReason) {
100 let counter = match reason {
101 CoverageReason::Analyzed => &mut self.analyzed,
102 CoverageReason::Binary => &mut self.binary,
103 CoverageReason::InvalidUtf8 => &mut self.invalid_utf8,
104 CoverageReason::UnsupportedEncoding => &mut self.unsupported_encoding,
105 CoverageReason::IoError => &mut self.io_errors,
106 CoverageReason::ChangedDuringRead => &mut self.changed_during_read,
107 CoverageReason::Unsupported => &mut self.unsupported,
108 };
109 *counter = counter.saturating_add(1);
110 }
111}
112
113pub fn analyze_index(index: &mut Index, request: AnalysisRequest) -> AnalysisReport {
120 analyze_index_observed(index, request, None)
121}
122
123pub(crate) fn analyze_index_observed(
131 index: &mut Index,
132 request: AnalysisRequest,
133 progress: Option<&crate::Progress>,
134) -> AnalysisReport {
135 if !request.profile.is_enabled() {
136 return AnalysisReport::default();
137 }
138 let pass_started_at_ns =
139 crate::query::system_time_to_nanos(std::time::SystemTime::now()).unwrap_or(0);
140 let previous_state = index.content().and_then(super::ContentIndex::state);
141 index.prepare_content_analysis(request);
142 let candidates = index.pending_analysis_candidates(request);
143 let mut report = AnalysisReport {
144 candidates: u64::try_from(candidates.len()).unwrap_or(u64::MAX),
145 code: request.profile.includes_code().then(AnalyzerCoverage::default),
146 words: request.profile.includes_words().then(AnalyzerCoverage::default),
147 ..AnalysisReport::default()
148 };
149 if let Some(progress) = progress {
150 progress.enter(crate::ProgressPhase::Analyzing);
151 progress.begin_analysis(report.candidates);
152 }
153 if candidates.is_empty() {
154 finish_content_tier(index, &report, previous_state, pass_started_at_ns);
155 return report;
156 }
157
158 let started = std::time::Instant::now();
159 let types = index.types_shared();
162 let workers = worker_count(request.workers, candidates.len());
163 let next = AtomicUsize::new(0);
164 let candidates = Arc::new(candidates);
165 let (sender, receiver) = mpsc::sync_channel(workers.saturating_mul(2).max(1));
166
167 std::thread::scope(|scope| {
168 for _ in 0..workers {
169 let sender = sender.clone();
170 let candidates = Arc::clone(&candidates);
171 let next = &next;
172 let types = &types;
173 scope.spawn(move || {
174 let _counter_guard = crate::counters::thread_flush_guard();
175 loop {
176 let slot = next.fetch_add(1, Ordering::Relaxed);
177 let Some(candidate) = candidates.get(slot).cloned() else { break };
178 if sender.send(analyze_candidate(types, candidate, request)).is_err() {
179 break;
180 }
181 }
182 });
183 }
184 drop(sender);
185 for (observation, bytes_read) in receiver {
186 report.bytes_read = report.bytes_read.saturating_add(bytes_read);
187 count_coverage(&mut report, &observation.analysis);
188 match index.apply_analysis(observation) {
189 AnalysisApplyOutcome::Applied => report.applied = report.applied.saturating_add(1),
190 AnalysisApplyOutcome::Stale => report.stale = report.stale.saturating_add(1),
191 }
192 if let Some(progress) = progress {
195 progress.add_analyzed(1);
196 }
197 }
198 });
199 report.elapsed_ns = elapsed_ns(started);
200 finish_content_tier(index, &report, previous_state, pass_started_at_ns);
201 report
202}
203
204fn finish_content_tier(
205 index: &mut Index,
206 report: &AnalysisReport,
207 previous: Option<super::ContentTierState>,
208 pass_started_at_ns: i64,
209) {
210 let entry = index.state();
211 let source = match entry.source {
212 crate::Source::Cached => crate::Source::Cached,
213 _ if previous.is_some_and(|state| state.source == crate::Source::Cached) => {
214 crate::Source::Revalidated
215 }
216 _ => crate::Source::Scanned,
217 };
218 let freshness = if report.is_complete() {
219 match source {
220 crate::Source::Cached => crate::Freshness::Stale,
221 _ => entry.freshness,
222 }
223 } else {
224 crate::Freshness::Partial
225 };
226 let observed_at_ns = if source == crate::Source::Cached {
227 previous.and_then(|state| state.observed_at_ns)
228 } else {
229 Some(pass_started_at_ns)
230 };
231 index.set_content_tier_state(source, freshness, observed_at_ns);
232}
233
234fn elapsed_ns(started: std::time::Instant) -> u64 {
235 u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX)
236}
237
238fn worker_count(requested: usize, candidates: usize) -> usize {
239 let available = std::thread::available_parallelism().map_or(1, NonZeroUsize::get);
240 let requested = if requested == 0 { available } else { requested };
241 requested.clamp(1, candidates.max(1))
242}
243
244fn analyze_candidate(
245 types: &TypeRegistry,
246 candidate: AnalysisCandidate,
247 request: AnalysisRequest,
248) -> (AnalysisObservation, u64) {
249 let (analysis, bytes_read) = if candidate.classification.family == ContentFamily::Binary {
250 (
251 record(
252 types,
253 &candidate,
254 request,
255 candidate.classification.clone(),
256 CoverageReason::Binary,
257 None,
258 ),
259 0,
260 )
261 } else {
262 analyze_open_file(types, &candidate, request)
263 };
264 let provenance = ContentProvenance::for_request(request, types.fingerprint());
265 (AnalysisObservation { candidate, profile: request.profile, provenance, analysis }, bytes_read)
266}
267
268fn analyze_open_file(
269 types: &TypeRegistry,
270 candidate: &AnalysisCandidate,
271 request: AnalysisRequest,
272) -> (FileAnalysis, u64) {
273 crate::counters::bump(|c| c.file_opens += 1);
274 let mut file = match File::open(&candidate.absolute_path) {
275 Ok(file) => file,
276 Err(error) => return (io_record(types, candidate, request, &error), 0),
277 };
278 let before = match file.metadata() {
279 Ok(metadata) => match crate::scan::attrs_from_file(&file, &metadata) {
280 Ok(attrs) => attrs.fingerprint(),
281 Err(error) => return (io_record(types, candidate, request, &error), 0),
282 },
283 Err(error) => return (io_record(types, candidate, request, &error), 0),
284 };
285 if before != candidate.attrs.fingerprint() {
286 return (
287 record(
288 types,
289 candidate,
290 request,
291 candidate.classification.clone(),
292 CoverageReason::ChangedDuringRead,
293 None,
294 ),
295 0,
296 );
297 }
298
299 let mut accumulator = BasicAccumulator::with_logical_metrics(request.profile.includes_words());
300 let mut code_accumulator = request
301 .profile
302 .includes_code()
303 .then(|| CodeAccumulator::for_type(candidate.classification.file_type.as_str()))
304 .flatten();
305 let mut deferred_code = (request.profile.includes_code()
306 && candidate.classification.family == ContentFamily::Unknown)
307 .then(Vec::new);
308 let mut markdown_source = (request.profile.includes_words()
309 && (candidate.classification.file_type.as_str() == "markdown"
310 || candidate.classification.family == ContentFamily::Unknown))
311 .then(Vec::new);
312 let mut prefix = Vec::with_capacity(CLASSIFICATION_PREFIX_BYTES);
313 let mut chunk = vec![0_u8; READ_CHUNK_BYTES];
314 let mut read_failure = None;
315 let mut early_binary = None;
316 let mut encoding_prefix = [0_u8; 4];
317 let mut encoding_prefix_len = 0_usize;
318 let mut encoding_decided = false;
319 let mut unsupported_encoding = false;
320 let mut bytes_read = 0_u64;
321 loop {
322 crate::counters::bump(|c| c.file_reads += 1);
323 match file.read(&mut chunk) {
324 Ok(0) => break,
325 Ok(count) => {
326 crate::counters::bump(|c| c.bytes_read += count as u64);
327 bytes_read = bytes_read.saturating_add(u64::try_from(count).unwrap_or(u64::MAX));
328 if prefix.len() < CLASSIFICATION_PREFIX_BYTES {
329 let take = count.min(CLASSIFICATION_PREFIX_BYTES - prefix.len());
330 prefix.extend_from_slice(&chunk[..take]);
331 }
332 let mut body = &chunk[..count];
333 if !encoding_decided {
334 let take = body.len().min(encoding_prefix.len() - encoding_prefix_len);
335 encoding_prefix[encoding_prefix_len..encoding_prefix_len + take]
336 .copy_from_slice(&body[..take]);
337 encoding_prefix_len += take;
338 body = &body[take..];
339 if has_unsupported_encoding_bom(&encoding_prefix[..encoding_prefix_len]) {
340 unsupported_encoding = true;
341 break;
342 }
343 if encoding_prefix_len < encoding_prefix.len() {
344 continue;
345 }
346 encoding_decided = true;
347 }
348 if prefix.len() >= 8 && candidate.classification.family == ContentFamily::Unknown {
349 let classification =
350 classify_with(types, &candidate.relative_path, Some(&prefix));
351 if classification.family == ContentFamily::Binary {
352 early_binary = Some(classification);
353 break;
354 }
355 if prefix.len() == CLASSIFICATION_PREFIX_BYTES {
356 if let Some(deferred) = deferred_code.take() {
357 if classification.family == ContentFamily::Code {
358 if let Some(mut code) =
359 CodeAccumulator::for_type(classification.file_type.as_str())
360 {
361 code.push(&deferred);
362 code_accumulator = Some(code);
363 }
364 }
365 }
366 if classification.file_type.as_str() != "markdown" {
367 markdown_source = None;
368 }
369 }
370 }
371 if encoding_decided && encoding_prefix_len != 0 {
372 push_analysis_bytes(
373 &mut accumulator,
374 &mut code_accumulator,
375 &mut deferred_code,
376 &mut markdown_source,
377 &encoding_prefix[..encoding_prefix_len],
378 );
379 encoding_prefix_len = 0;
380 }
381 push_analysis_bytes(
382 &mut accumulator,
383 &mut code_accumulator,
384 &mut deferred_code,
385 &mut markdown_source,
386 body,
387 );
388 }
389 Err(error) => {
390 read_failure = Some(error);
391 break;
392 }
393 }
394 }
395
396 let after = match file.metadata() {
397 Ok(metadata) => match crate::scan::attrs_from_file(&file, &metadata) {
398 Ok(attrs) => attrs.fingerprint(),
399 Err(error) => return (io_record(types, candidate, request, &error), bytes_read),
400 },
401 Err(error) => return (io_record(types, candidate, request, &error), bytes_read),
402 };
403 if before != after {
404 return (
405 record(
406 types,
407 candidate,
408 request,
409 candidate.classification.clone(),
410 CoverageReason::ChangedDuringRead,
411 None,
412 ),
413 bytes_read,
414 );
415 }
416 if let Some(error) = read_failure {
417 return (io_record(types, candidate, request, &error), bytes_read);
418 }
419 if unsupported_encoding {
420 return (
421 record(
422 types,
423 candidate,
424 request,
425 candidate.classification.clone(),
426 CoverageReason::UnsupportedEncoding,
427 None,
428 ),
429 bytes_read,
430 );
431 }
432 if encoding_prefix_len != 0 {
433 push_analysis_bytes(
434 &mut accumulator,
435 &mut code_accumulator,
436 &mut deferred_code,
437 &mut markdown_source,
438 &encoding_prefix[..encoding_prefix_len],
439 );
440 }
441 if let Some(classification) = early_binary {
442 return (
443 record(types, candidate, request, classification, CoverageReason::Binary, None),
444 bytes_read,
445 );
446 }
447 let classification = classify_with(types, &candidate.relative_path, Some(&prefix));
448 if classification.family == ContentFamily::Binary {
449 return (
450 record(types, candidate, request, classification, CoverageReason::Binary, None),
451 bytes_read,
452 );
453 }
454 let analysis = match accumulator.finish() {
455 TextAdmission::Accepted(mut metrics) => {
456 let mut code_supported = !request.profile.includes_code();
463 if request.profile.includes_code() {
464 if code_accumulator.is_none() && classification.family == ContentFamily::Code {
465 if let Some(deferred) = deferred_code {
466 if let Some(mut code) =
467 CodeAccumulator::for_type(classification.file_type.as_str())
468 {
469 code.push(&deferred);
470 code_accumulator = Some(code);
471 }
472 }
473 }
474 code_supported = code_accumulator.is_some();
475 if let Some(code) = code_accumulator.take() {
476 let code_metrics = code.finish();
477 debug_assert_eq!(metrics.physical_lines, code_metrics.physical_lines);
478 metrics.code_lines = code_metrics.code_lines;
479 metrics.comment_lines = code_metrics.comment_lines;
480 metrics.code_blank_lines = code_metrics.code_blank_lines;
481 }
482 }
483 if request.profile.includes_words() && classification.file_type.as_str() == "markdown" {
484 if let Some(source) = markdown_source {
485 let source = std::str::from_utf8(&source)
486 .expect("basic admission already established valid UTF-8");
487 let visible = analyze_markdown(source);
488 metrics.visible_words = visible.visible_words;
489 metrics.visible_logical_word_stats = visible.visible_logical_word_stats;
490 metrics.paragraphs = visible.paragraphs;
491 }
492 }
493 let lines = BasicMetrics {
494 physical_lines: metrics.physical_lines,
495 blank_lines: metrics.blank_lines,
496 nonblank_lines: metrics.nonblank_lines,
497 raw_words: metrics.raw_words,
498 };
499 let code = request.profile.includes_code().then(|| {
500 if code_supported {
501 AnalyzerOutcome::analyzed(CodeMetrics {
502 code_lines: metrics.code_lines,
503 comment_lines: metrics.comment_lines,
504 code_blank_lines: metrics.code_blank_lines,
505 })
506 } else {
507 AnalyzerOutcome::unavailable(CoverageReason::Unsupported)
508 }
509 });
510 let words = request.profile.includes_words().then_some(AnalyzerOutcome::analyzed(
511 WordMetrics {
512 paragraphs: metrics.paragraphs,
513 visible_words: metrics.visible_words,
514 logical_word_stats: metrics.logical_word_stats,
515 visible_logical_word_stats: metrics.visible_logical_word_stats,
516 },
517 ));
518 analyzed_record(candidate, classification, lines, code, words)
519 }
520 TextAdmission::Binary => {
521 record(types, candidate, request, classification, CoverageReason::Binary, None)
522 }
523 TextAdmission::InvalidUtf8 => {
524 record(types, candidate, request, classification, CoverageReason::InvalidUtf8, None)
525 }
526 };
527 (analysis, bytes_read)
528}
529
530fn has_unsupported_encoding_bom(prefix: &[u8]) -> bool {
531 prefix.starts_with(&[0xff, 0xfe])
532 || prefix.starts_with(&[0xfe, 0xff])
533 || prefix.starts_with(&[0x00, 0x00, 0xfe, 0xff])
534}
535
536fn push_analysis_bytes(
537 accumulator: &mut BasicAccumulator,
538 code_accumulator: &mut Option<CodeAccumulator>,
539 deferred_code: &mut Option<Vec<u8>>,
540 markdown_source: &mut Option<Vec<u8>>,
541 bytes: &[u8],
542) {
543 accumulator.push(bytes);
544 if let Some(code) = code_accumulator {
545 code.push(bytes);
546 }
547 if let Some(deferred) = deferred_code {
548 deferred.extend_from_slice(bytes);
549 }
550 if let Some(source) = markdown_source {
551 source.extend_from_slice(bytes);
552 }
553}
554
555fn analyzed_record(
556 candidate: &AnalysisCandidate,
557 classification: crate::classify::Classification,
558 lines: BasicMetrics,
559 code: Option<AnalyzerOutcome<CodeMetrics>>,
560 words: Option<AnalyzerOutcome<WordMetrics>>,
561) -> FileAnalysis {
562 FileAnalysis {
563 fingerprint: candidate.attrs.fingerprint(),
564 bytes: candidate.attrs.size,
565 detection: classification.into(),
566 lines: AnalyzerOutcome::analyzed(lines),
567 code,
568 words,
569 error: None,
570 }
571}
572
573fn io_record(
574 types: &TypeRegistry,
575 candidate: &AnalysisCandidate,
576 request: AnalysisRequest,
577 error: &std::io::Error,
578) -> FileAnalysis {
579 let mut detail = error.to_string();
580 detail.truncate(char_boundary_at_or_before(&detail, MAX_ERROR_BYTES));
581 record(
582 types,
583 candidate,
584 request,
585 candidate.classification.clone(),
586 CoverageReason::IoError,
587 Some(detail),
588 )
589}
590
591fn char_boundary_at_or_before(value: &str, limit: usize) -> usize {
592 let mut boundary = limit.min(value.len());
593 while !value.is_char_boundary(boundary) {
594 boundary -= 1;
595 }
596 boundary
597}
598
599fn record(
600 _types: &TypeRegistry,
601 candidate: &AnalysisCandidate,
602 request: AnalysisRequest,
603 classification: crate::classify::Classification,
604 coverage: CoverageReason,
605 error: Option<String>,
606) -> FileAnalysis {
607 FileAnalysis {
608 fingerprint: candidate.attrs.fingerprint(),
609 bytes: candidate.attrs.size,
610 detection: classification.into(),
611 lines: AnalyzerOutcome::unavailable(coverage),
612 code: request.profile.includes_code().then_some(AnalyzerOutcome::unavailable(coverage)),
613 words: request.profile.includes_words().then_some(AnalyzerOutcome::unavailable(coverage)),
614 error,
615 }
616}
617
618fn count_coverage(report: &mut AnalysisReport, analysis: &FileAnalysis) {
619 report.lines.count(analysis.lines.coverage());
620 if let (Some(coverage), Some(outcome)) = (&mut report.code, analysis.code) {
621 coverage.count(outcome.coverage());
622 }
623 if let (Some(coverage), Some(outcome)) = (&mut report.words, analysis.words) {
624 coverage.count(outcome.coverage());
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use std::collections::BTreeMap;
631 use std::fs;
632 use std::path::Path;
633
634 use crate::content::AnalysisSet;
635 use crate::scan::ScanConfig;
636
637 use super::*;
638
639 const LARGE_CODE_FILE_BYTES: usize = 17 * 1024 * 1024;
641
642 #[test]
643 fn expected_coverage_gaps_are_not_operational_failures() {
644 let report = AnalysisReport {
645 lines: AnalyzerCoverage { invalid_utf8: 1, ..AnalyzerCoverage::default() },
646 code: Some(AnalyzerCoverage { unsupported: 3, ..AnalyzerCoverage::default() }),
647 ..AnalysisReport::default()
648 };
649
650 assert!(report.is_complete());
651 assert_eq!(report.failure_message(), None);
652
653 let failed = AnalysisReport {
654 lines: AnalyzerCoverage {
655 io_errors: 1,
656 changed_during_read: 2,
657 ..AnalyzerCoverage::default()
658 },
659 stale: 3,
660 ..AnalysisReport::default()
661 };
662 assert!(!failed.is_complete());
663 assert_eq!(
664 failed.failure_message().as_deref(),
665 Some(
666 "content analysis had operational failures (I/O errors: 1; changed during read: 2; stale results: 3). File and byte totals remain complete; content metrics omit affected files"
667 )
668 );
669 }
670
671 #[test]
673 fn code_carries_word_volume_and_paragraphs() {
674 let root = tempfile::tempdir().expect("tempdir");
675 std::fs::write(root.path().join("main.rs"), b"fn main() {\n\n let x = 1;\n}\n")
676 .expect("write");
677 std::fs::write(root.path().join("notes.md"), b"one two\n\nthree four\n").expect("write");
678 let (mut index, _) =
679 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
680 analyze_index(
681 &mut index,
682 AnalysisRequest { profile: super::super::AnalysisSet::ALL, workers: 2 },
683 );
684
685 let content = index.content().expect("content");
686 let code = content.file(std::path::Path::new("main.rs")).expect("code record");
687 assert!(code.lines.value().expect("line metrics").raw_words > 0);
688 assert_eq!(code.code.expect("code outcome").coverage(), CoverageReason::Analyzed);
689 assert!(code.words.and_then(AnalyzerOutcome::value).expect("word metrics").paragraphs > 0);
690
691 let prose = content.file(std::path::Path::new("notes.md")).expect("prose record");
692 assert!(prose.lines.value().expect("line metrics").raw_words > 0);
693 assert_eq!(prose.code.expect("code outcome").coverage(), CoverageReason::Unsupported);
694 assert!(prose.words.and_then(AnalyzerOutcome::value).expect("word metrics").paragraphs > 0);
695 }
696
697 #[test]
698 fn every_metric_is_independent_of_other_requested_units_and_grouping_is_name_only() {
699 let root = tempfile::tempdir().expect("tempdir");
700 fs::create_dir_all(root.path().join("generated")).expect("generated directory");
701 for (path, bytes) in [
702 ("main.rs", b"// comment\nfn main() { println!(\"hello world\"); }\n".as_slice()),
703 ("tool.py", b"# comment\nprint('hello world')\n"),
704 ("Main.hs", b"-- comment\nmain = putStrLn \"hello world\"\n"),
705 ("guide.md", b"# Hello\n\nVisible [words](https://example.test).\n"),
706 ("notes.txt", b"first paragraph words\n\nsecond paragraph words\n"),
707 ("ambiguous.h", b"// generated fixture\nnamespace demo { int value; }\n"),
708 ("script", b"#!/usr/bin/env python3\nprint('from shebang')\n"),
709 ("document", b"%PDF-1.7\nfixture"),
710 ("nul.unknown", b"text before\0binary"),
711 ("invalid.unknown", &[b't', b'e', b'x', b't', 0xff]),
712 ("generated/output.rs", b"// Code generated; DO NOT EDIT.\nfn output() {}\n"),
713 ] {
714 fs::write(root.path().join(path), bytes).expect("write fixture");
715 }
716 let (baseline, scan) =
717 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
718 assert!(scan.is_complete());
719
720 let profiles = [
721 AnalysisSet::LINES_ONLY,
722 AnalysisSet::CODE_ONLY,
723 AnalysisSet::WORDS_ONLY,
724 AnalysisSet::ALL,
725 ];
726 let mut observed = Vec::new();
727 for profile in profiles {
728 let mut index = baseline.clone();
729 let analysis = analyze_index(&mut index, AnalysisRequest { profile, workers: 2 });
730 assert!(analysis.is_complete(), "{profile:?}: {analysis:?}");
731 let query = crate::query::Query {
732 views: vec![crate::query::ViewSpec::Types],
733 ..crate::query::Query::default()
734 };
735 let report = crate::query::report(
736 &index,
737 &crate::test_support::read_of(&index, query),
738 std::time::UNIX_EPOCH,
739 )
740 .expect("report");
741 let crate::query::Section::Metrics { summary, .. } = &report.sections[0] else {
742 panic!("expected type metrics")
743 };
744 let mut groups: Vec<_> = summary.rows.iter().map(|row| row.id.clone()).collect();
745 groups.sort();
746 let values: std::collections::BTreeMap<_, _> = std::iter::once(&summary.total)
747 .chain(&summary.rows)
748 .map(|row| {
749 let metrics: Vec<_> = crate::content::METRICS
750 .iter()
751 .map(|metric| row.metric_value(metric))
752 .collect();
753 (row.id.clone(), metrics)
754 })
755 .collect();
756 observed.push((profile, groups, values));
757 }
758
759 for (_, groups, _) in &observed[1..] {
760 assert_eq!(groups, &observed[0].1, "requested analyzers must not change type groups");
761 }
762 for (metric_index, metric) in crate::content::METRICS.iter().enumerate() {
763 let (_, _, baseline) = observed
764 .iter()
765 .find(|(profile, _, _)| profile.contains(metric.owner))
766 .expect("an owning profile exists");
767 for (profile, _, rows) in &observed {
768 assert_eq!(rows.keys().collect::<Vec<_>>(), baseline.keys().collect::<Vec<_>>());
769 for (id, values) in rows {
770 let expected = if profile.contains(metric.owner) {
771 Some(baseline[id][metric_index].expect("owning profile exposes metric"))
772 } else {
773 None
774 };
775 assert_eq!(
776 values[metric_index], expected,
777 "{} changed or leaked in row {id} under {profile:?}",
778 metric.name
779 );
780 }
781 }
782 }
783 }
784
785 #[test]
786 fn prefix_classification_handoff_neither_drops_nor_double_counts_large_files() {
787 let root = tempfile::tempdir().expect("tempdir");
788 for size in [20 * 1024, 80 * 1024] {
789 let mut python = b"#!/usr/bin/env python3\n".to_vec();
790 while python.len() < size {
791 python.extend_from_slice(b"value = 1 # one comment\n");
792 }
793 fs::write(root.path().join(format!("script-{size}")), &python).expect("shebang");
794 fs::write(root.path().join(format!("script-{size}.py")), &python).expect("python");
795
796 let mut plain = Vec::new();
797 while plain.len() < size {
798 plain.extend_from_slice(b"ordinary words in a plain text line\n");
799 }
800 fs::write(root.path().join(format!("plain-{size}.unknown")), &plain)
801 .expect("unknown text");
802 fs::write(root.path().join(format!("plain-{size}.txt")), &plain).expect("text");
803 }
804 let (baseline, scan) =
805 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
806 assert!(scan.is_complete());
807
808 for profile in [AnalysisSet::CODE_ONLY, AnalysisSet::WORDS_ONLY, AnalysisSet::ALL] {
809 let mut index = baseline.clone();
810 let report = analyze_index(&mut index, AnalysisRequest { profile, workers: 1 });
811 assert!(report.is_complete(), "{profile:?}: {report:?}");
812 let content = index.content().expect("content");
813 for size in [20 * 1024, 80 * 1024] {
814 let script = content
815 .file(Path::new(&format!("script-{size}")))
816 .expect("extensionless script");
817 let python =
818 content.file(Path::new(&format!("script-{size}.py"))).expect("named Python");
819 assert_eq!(script.lines.value(), python.lines.value(), "{profile:?}, {size} bytes");
820 if profile.includes_code() {
821 assert_eq!(script.code, python.code, "code handoff at {size} bytes");
822 }
823 if profile.includes_words() {
824 assert_eq!(script.words, python.words, "word handoff at {size} bytes");
825 }
826
827 let unknown = content
828 .file(Path::new(&format!("plain-{size}.unknown")))
829 .expect("unknown text");
830 let text =
831 content.file(Path::new(&format!("plain-{size}.txt"))).expect("named text");
832 assert_eq!(unknown.lines.value(), text.lines.value(), "plain lines at {size}");
833 if profile.includes_words() {
834 assert_eq!(unknown.words, text.words, "plain words at {size}");
835 }
836 }
837 }
838 }
839
840 #[test]
841 fn pool_analyzes_text_and_skips_known_binary_files() {
842 let root = tempfile::tempdir().expect("tempdir");
843 fs::write(root.path().join("notes.md"), "one two\n\nthree\n").expect("write text");
844 fs::write(root.path().join("image.png"), b"not opened as text").expect("write binary");
845 let (mut index, scan) =
846 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
847 assert!(scan.is_complete());
848
849 let report = analyze_index(
850 &mut index,
851 AnalysisRequest { profile: super::super::AnalysisSet::NONE.with_lines(), workers: 2 },
852 );
853
854 assert_eq!(report.candidates, 2);
855 assert_eq!(report.lines.analyzed, 1);
856 assert_eq!(report.lines.binary, 1);
857 assert_eq!(report.bytes_read, 15, "known binary files must not be opened");
858 assert!(report.elapsed_ns > 0);
859 let root_rollup = index.content_rollup(std::path::Path::new("")).expect("content root");
860 assert_eq!(root_rollup.total.files, 2);
861 assert_eq!(root_rollup.total.lines.metrics.physical_lines, 3);
862 assert_eq!(root_rollup.total.lines.metrics.raw_words, 3);
863 }
864
865 #[test]
866 fn content_workers_publish_their_file_io_counters() {
867 let _serial = crate::counters::test_serial();
868 let root = tempfile::tempdir().expect("tempdir");
869 fs::write(root.path().join("notes.md"), "one two\n\nthree\n").expect("write text");
870 let (mut index, scan) =
871 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
872 assert!(scan.is_complete());
873
874 crate::counters::enable(true);
875 crate::counters::reset();
876 let report = analyze_index(
877 &mut index,
878 AnalysisRequest { profile: super::super::AnalysisSet::NONE.with_lines(), workers: 2 },
879 );
880 crate::counters::flush_thread();
881 let counts = crate::counters::snapshot();
882 crate::counters::reset();
883 crate::counters::enable(false);
884
885 assert_eq!(report.lines.analyzed, 1);
886 assert!(counts.file_opens >= 1, "content worker file open was folded: {counts:?}");
887 assert!(counts.file_reads >= 2, "data and EOF reads were folded: {counts:?}");
888 assert!(counts.bytes_read >= 15, "content bytes were folded: {counts:?}");
889 }
890
891 #[test]
892 fn large_generated_code_is_analyzed_through_eof() {
893 let root = tempfile::tempdir().expect("tempdir");
894 let mut source = b"/* generated */\n".to_vec();
895 source.resize(LARGE_CODE_FILE_BYTES - 1, b'x');
896 source.push(b'\n');
897 fs::write(root.path().join("generated.c"), source).expect("write generated C");
898 let (mut index, _) =
899 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
900
901 let report = analyze_index(
902 &mut index,
903 AnalysisRequest { profile: super::super::AnalysisSet::NONE.with_code(), workers: 1 },
904 );
905
906 assert_eq!(report.lines.analyzed, 1);
907 assert_eq!(report.bytes_read, LARGE_CODE_FILE_BYTES as u64);
908 assert!(report.elapsed_ns > 0);
909 assert!(report.is_complete());
910 let metrics = &index.content_rollup(std::path::Path::new("")).expect("content root").total;
911 assert_eq!(metrics.bytes, LARGE_CODE_FILE_BYTES as u64);
912 assert_eq!(metrics.lines.metrics.physical_lines, 2);
913 assert_eq!(metrics.code.metrics.code_lines, 1);
914 assert_eq!(metrics.code.metrics.comment_lines, 1);
915 }
916
917 #[test]
918 fn nul_and_invalid_utf8_are_coverage_not_provisional_metrics() {
919 let root = tempfile::tempdir().expect("tempdir");
920 fs::write(root.path().join("nul.unknown"), b"line\nlate\0nul").expect("write nul");
921 fs::write(root.path().join("bad.unknown"), [b'a', 0xff]).expect("write invalid");
922 let (mut index, _) =
923 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
924
925 let report = analyze_index(
926 &mut index,
927 AnalysisRequest {
928 profile: super::super::AnalysisSet::NONE.with_lines(),
929 ..AnalysisRequest::default()
930 },
931 );
932 assert_eq!(report.lines.binary, 1);
933 assert_eq!(report.lines.invalid_utf8, 1);
934 let metrics = &index.content_rollup(std::path::Path::new("")).expect("root").total.lines;
935 assert_eq!(metrics.metrics, BasicMetrics::default());
936 }
937
938 #[test]
939 fn deep_detection_drives_named_consumers_and_report_evidence() {
940 let root = tempfile::tempdir().expect("tempdir");
941 fs::create_dir_all(root.path().join("vendor/docs")).expect("directories");
942 fs::write(
943 root.path().join("vendor/docs/generated.h"),
944 "// Code generated by fixture; DO NOT EDIT.\nnamespace demo { int value; }\n",
945 )
946 .expect("write header");
947 fs::write(
948 root.path().join("script.inc"),
949 "# vim: set filetype=rust:\n// comment\nfn main() {}\n",
950 )
951 .expect("write modeline");
952 fs::write(root.path().join("download"), b"%PDF-1.7\nfixture payload")
953 .expect("write signature");
954 let (mut index, _) =
955 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
956
957 let report = analyze_index(
958 &mut index,
959 AnalysisRequest {
960 profile: super::super::AnalysisSet::NONE.with_code(),
961 ..AnalysisRequest::default()
962 },
963 );
964 assert_eq!(report.lines.analyzed, 2);
965 assert_eq!(report.lines.binary, 1);
966
967 let content = index.content().expect("content");
968 let header = content.file(std::path::Path::new("vendor/docs/generated.h")).expect("header");
969 assert_eq!(header.detection.file_type.as_str(), "cpp");
970 assert_eq!(header.detection.source, crate::classify::DetectionSource::AmbiguousContent);
971 assert!(header.detection.flags.generated);
972 assert!(header.detection.flags.vendored);
973 assert!(header.detection.flags.documentation);
974
975 let script = content.file(std::path::Path::new("script.inc")).expect("script");
976 assert_eq!(script.detection.file_type.as_str(), "rust");
977 let script_code = script.code.and_then(AnalyzerOutcome::value).expect("code metrics");
978 assert_eq!(script_code.comment_lines, 1);
979 assert_eq!(script_code.code_lines, 2);
980
981 let pdf = content.file(std::path::Path::new("download")).expect("pdf");
982 assert_eq!(pdf.detection.file_type.as_str(), "pdf");
983 assert_eq!(pdf.lines.coverage(), CoverageReason::Binary);
984
985 let query = crate::query::Query {
986 views: vec![crate::query::ViewSpec::Types],
987 ..crate::query::Query::default()
988 };
989 let report = crate::query::report(
990 &index,
991 &crate::test_support::read_of(&index, query),
992 std::time::UNIX_EPOCH,
993 )
994 .expect("report");
995 let crate::query::Section::Metrics { summary, .. } = &report.sections[0] else {
996 panic!("expected metric summary")
997 };
998 assert_eq!(summary.total.generated_files, 1);
999 assert_eq!(summary.total.vendored_files, 1);
1000 assert_eq!(summary.total.documentation_files, 1);
1001 assert_eq!(
1002 summary.total.detection_sources.get(&crate::classify::DetectionSource::FormatSignature),
1003 Some(&1)
1004 );
1005 let header_row = summary.rows.iter().find(|row| row.id == "c").expect("name group");
1006 assert_eq!(
1007 header_row.detection_sources.get(&crate::classify::DetectionSource::AmbiguousContent),
1008 Some(&1),
1009 "content evidence remains visible without reassigning the .h name group"
1010 );
1011 let script_row = summary
1012 .rows
1013 .iter()
1014 .find(|row| row.id == "unknown:.inc")
1015 .expect("unresolved name group");
1016 assert_eq!(
1017 script_row.detection_sources.get(&crate::classify::DetectionSource::Modeline),
1018 Some(&1),
1019 "the modeline is evidence even though grouping follows the filename"
1020 );
1021 }
1022
1023 #[test]
1024 fn code_profile_partitions_supported_languages_and_marks_others_unsupported() {
1025 let root = tempfile::tempdir().expect("tempdir");
1026 fs::write(root.path().join("main.rs"), "// comment\nfn main() {} // mixed\n\n")
1027 .expect("write rust");
1028 fs::write(root.path().join("Main.hs"), "-- not claimed\nmain = pure ()\n")
1029 .expect("write haskell");
1030 let (mut index, _) =
1031 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1032
1033 let report = analyze_index(
1034 &mut index,
1035 AnalysisRequest {
1036 profile: super::super::AnalysisSet::NONE.with_code(),
1037 ..AnalysisRequest::default()
1038 },
1039 );
1040
1041 assert_eq!(report.lines.analyzed, 2);
1042 assert_eq!(report.code.expect("code coverage").unsupported, 1);
1043 let rust = index.content().expect("content").file(std::path::Path::new("main.rs"));
1044 let rust = rust.expect("rust record");
1045 let lines = rust.lines.value().expect("line metrics");
1046 let metrics = rust.code.and_then(AnalyzerOutcome::value).expect("code metrics");
1047 assert_eq!(lines.physical_lines, 3);
1048 assert_eq!(metrics.code_lines, 1);
1049 assert_eq!(metrics.comment_lines, 1);
1050 assert_eq!(metrics.code_blank_lines, 1);
1051 assert_eq!(
1052 lines.physical_lines,
1053 metrics.code_lines + metrics.comment_lines + metrics.code_blank_lines
1054 );
1055 let haskell = index.content().expect("content").file(std::path::Path::new("Main.hs"));
1056 let haskell = haskell.expect("haskell record");
1057 assert_eq!(haskell.lines.coverage(), CoverageReason::Analyzed);
1058 assert_eq!(haskell.code.expect("code outcome").coverage(), CoverageReason::Unsupported);
1059
1060 let query = crate::query::Query {
1061 views: vec![crate::query::ViewSpec::Languages],
1062 ..crate::query::Query::default()
1063 };
1064 let rendered = crate::query::report(
1065 &index,
1066 &crate::test_support::read_of(&index, query),
1067 std::time::UNIX_EPOCH,
1068 )
1069 .expect("report");
1070 let crate::query::Section::Metrics { summary, .. } = &rendered.sections[0] else {
1071 panic!("expected language metrics")
1072 };
1073 assert_eq!(summary.share_metric, crate::query::ShareMetric::CodeLines);
1074 assert_eq!((summary.total.share.numerator, summary.total.share.denominator), (1, 1));
1075 assert_eq!(summary.total.coverage.get(&CoverageReason::Unsupported), Some(&1));
1076 }
1077
1078 #[test]
1079 fn unicode_boms_are_nonoperational_unsupported_encoding_outcomes_per_unit() {
1080 let root = tempfile::tempdir().expect("tempdir");
1081 fs::write(root.path().join("little.rs"), [0xff, 0xfe, b'f', 0, b'n', 0])
1082 .expect("UTF-16 LE");
1083 fs::write(root.path().join("big.txt"), [0xfe, 0xff, 0, b'w']).expect("UTF-16 BE");
1084 fs::write(root.path().join("wide"), [0, 0, 0xfe, 0xff, 0, 0, 0, b'w']).expect("UTF-32 BE");
1085 fs::write(root.path().join("notes.md"), b"ordinary words\n").expect("UTF-8");
1086 let (mut index, _) =
1087 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1088
1089 let report = analyze_index(
1090 &mut index,
1091 AnalysisRequest { profile: AnalysisSet::ALL, ..AnalysisRequest::default() },
1092 );
1093
1094 assert!(report.is_complete(), "unsupported encodings are not operational failures");
1095 assert_eq!(report.lines.unsupported_encoding, 3);
1096 assert_eq!(report.code.expect("code coverage").unsupported_encoding, 3);
1097 assert_eq!(report.words.expect("word coverage").unsupported_encoding, 3);
1098 assert_eq!(report.lines.binary, 0);
1099 assert_eq!(report.lines.invalid_utf8, 0);
1100 let query = crate::query::Query {
1101 views: vec![crate::query::ViewSpec::Types],
1102 ..crate::query::Query::default()
1103 };
1104 let rendered = crate::query::report(
1105 &index,
1106 &crate::test_support::read_of(&index, query),
1107 std::time::UNIX_EPOCH,
1108 )
1109 .expect("report");
1110 for format in [crate::report_format::Format::Json, crate::report_format::Format::Yaml] {
1111 let output = crate::report_format::render(&rendered, format, false)
1112 .expect("compatible report format");
1113 let total = match format {
1114 crate::report_format::Format::Json => {
1115 output.split("\"rows\":").next().expect("metrics total")
1116 }
1117 crate::report_format::Format::Yaml => {
1118 output.split("\n rows:").next().expect("metrics total")
1119 }
1120 _ => unreachable!("only machine document formats are tested"),
1121 };
1122 assert_eq!(
1123 total.matches("unsupported_encoding").count(),
1124 3,
1125 "each requested analyzer unit has its own total coverage map: {output}"
1126 );
1127 }
1128 let text =
1129 crate::report_format::render(&rendered, crate::report_format::Format::Text, false)
1130 .expect("compatible report format");
1131 assert!(
1132 text.contains("1 lines (1 nonblank, 0 blank), 2 words"),
1133 "unsupported code coverage cannot turn prose lines into a zero code partition: {text}"
1134 );
1135 let content = index.content().expect("content");
1136 for path in ["little.rs", "big.txt", "wide"] {
1137 let record = content.file(Path::new(path)).expect("encoding record");
1138 assert_eq!(record.lines.coverage(), CoverageReason::UnsupportedEncoding);
1139 assert_eq!(
1140 record.code.expect("code outcome").coverage(),
1141 CoverageReason::UnsupportedEncoding
1142 );
1143 assert_eq!(
1144 record.words.expect("word outcome").coverage(),
1145 CoverageReason::UnsupportedEncoding
1146 );
1147 assert!(record.error.is_none());
1148 }
1149 assert_eq!(
1150 content
1151 .file(Path::new("notes.md"))
1152 .expect("markdown record")
1153 .code
1154 .expect("code outcome")
1155 .coverage(),
1156 CoverageReason::Unsupported,
1157 "an unsupported analyzer remains distinct from an unsupported encoding"
1158 );
1159 }
1160
1161 #[test]
1162 fn unsupported_encoding_boms_are_recognized_from_progressive_prefixes() {
1163 for (bom, recognized_at) in [
1164 (&[0xff, 0xfe][..], 2_usize),
1165 (&[0xfe, 0xff][..], 2_usize),
1166 (&[0xff, 0xfe, 0, 0][..], 2_usize),
1167 (&[0, 0, 0xfe, 0xff][..], 4_usize),
1168 ] {
1169 for length in 0..=bom.len() {
1170 assert_eq!(
1171 has_unsupported_encoding_bom(&bom[..length]),
1172 length >= recognized_at,
1173 "{length} byte prefix of {bom:?}"
1174 );
1175 }
1176 }
1177 assert!(!has_unsupported_encoding_bom(&[0xef, 0xbb, 0xbf, b'x']));
1178 }
1179
1180 #[test]
1181 fn document_profile_uses_visible_markdown_and_logical_plain_text() {
1182 let root = tempfile::tempdir().expect("tempdir");
1183 fs::write(
1184 root.path().join("guide.md"),
1185 "# Read [the label](https://example.test)\n\n`hidden code` 中文\n",
1186 )
1187 .expect("write markdown");
1188 fs::write(root.path().join("notes.txt"), "oneverylongtoken\n\nplain words\n")
1189 .expect("write text");
1190 let (mut index, _) =
1191 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1192
1193 let report = analyze_index(
1194 &mut index,
1195 AnalysisRequest {
1196 profile: super::super::AnalysisSet::NONE.with_words(),
1197 ..AnalysisRequest::default()
1198 },
1199 );
1200 assert!(report.is_complete());
1201
1202 let query = crate::query::Query {
1203 views: vec![crate::query::ViewSpec::Documents],
1204 ..crate::query::Query::default()
1205 };
1206 let rendered = crate::query::report(
1207 &index,
1208 &crate::test_support::read_of(&index, query),
1209 std::time::UNIX_EPOCH,
1210 )
1211 .expect("report");
1212 let crate::query::Section::Metrics { summary, .. } = &rendered.sections[0] else {
1213 panic!("expected document metrics")
1214 };
1215 assert_eq!(summary.share_metric, crate::query::ShareMetric::DocumentWords);
1216 let rows =
1217 summary.rows.iter().map(|row| (row.id.as_str(), row)).collect::<BTreeMap<_, _>>();
1218 let markdown = rows["markdown"];
1219 assert!(markdown.metrics.raw_words > markdown.metrics.visible_words);
1220 assert_eq!(markdown.metrics.visible_words, Some(4));
1221 assert_eq!(markdown.metrics.paragraphs, Some(2));
1222 let text = rows["text"];
1223 assert!(text.metrics.logical_words > text.metrics.raw_words);
1224 assert_eq!(crate::query::document_words(&summary.total), Some(7));
1225 }
1226
1227 #[test]
1228 fn an_empty_analysis_still_retains_the_requested_identity() {
1229 let root = tempfile::tempdir().expect("tempdir");
1230 let (mut index, _) =
1231 crate::scan::scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
1232 let request = AnalysisRequest {
1233 profile: super::super::AnalysisSet::NONE.with_lines(),
1234 ..AnalysisRequest::default()
1235 };
1236
1237 let analysis = analyze_index(&mut index, request);
1238
1239 assert_eq!(analysis.candidates, 0);
1240 let content = index.content().expect("the requested derived tier remains explicit");
1241 assert_eq!(content.profile(), Some(request.profile));
1242 assert_eq!(
1243 content.provenance(),
1244 Some(ContentProvenance::for_request(request, crate::classify::type_rule_fingerprint()))
1245 );
1246 }
1247
1248 #[test]
1249 fn content_reports_preserve_empty_profiles_and_unavailable_shares() {
1250 let empty = tempfile::tempdir().expect("empty tempdir");
1251 let (mut empty_index, _) =
1252 crate::scan::scan_into_index(empty.path(), &ScanConfig::default()).expect("scan");
1253 analyze_index(
1254 &mut empty_index,
1255 AnalysisRequest {
1256 profile: super::super::AnalysisSet::NONE.with_lines(),
1257 ..AnalysisRequest::default()
1258 },
1259 );
1260 let summary = crate::query::report(
1261 &empty_index,
1262 &crate::test_support::read_of(
1263 &empty_index,
1264 crate::query::Query {
1265 views: vec![crate::query::ViewSpec::Summary],
1266 ..crate::query::Query::default()
1267 },
1268 ),
1269 std::time::UNIX_EPOCH,
1270 )
1271 .expect("report");
1272 let json =
1273 crate::report_format::render(&summary, crate::report_format::Format::Json, false)
1274 .expect("compatible report format");
1275 assert!(json.contains("\"schema\": \"fdu.report/10\""), "{json}");
1276 assert!(json.contains("\"analyze\": [\"lines\"]"), "{json}");
1277
1278 let unsupported = tempfile::tempdir().expect("unsupported tempdir");
1279 fs::write(unsupported.path().join("Main.hs"), "main = pure ()\n").expect("write");
1280 let (mut unsupported_index, _) =
1281 crate::scan::scan_into_index(unsupported.path(), &ScanConfig::default()).expect("scan");
1282 analyze_index(
1283 &mut unsupported_index,
1284 AnalysisRequest {
1285 profile: super::super::AnalysisSet::NONE.with_code(),
1286 ..AnalysisRequest::default()
1287 },
1288 );
1289 let languages = crate::query::report(
1290 &unsupported_index,
1291 &crate::test_support::read_of(
1292 &unsupported_index,
1293 crate::query::Query {
1294 views: vec![crate::query::ViewSpec::Languages],
1295 ..crate::query::Query::default()
1296 },
1297 ),
1298 std::time::UNIX_EPOCH,
1299 )
1300 .expect("report");
1301 let text =
1302 crate::report_format::render(&languages, crate::report_format::Format::Text, false)
1303 .expect("compatible report format");
1304 assert!(text.contains("—"), "an unavailable 0/0 share needs a distinct marker: {text}");
1305 assert!(
1306 text.contains("1 lines (1 nonblank, 0 blank), 1 unsupported"),
1307 "unsupported code falls back to the valid line partition: {text}"
1308 );
1309 assert!(!text.contains("0.0%"), "unmeasured is not a zero percentage: {text}");
1310 }
1311}