Skip to main content

fdu_core/content/
content_analysis.rs

1//! Parallel streaming file reads and conditional analysis commits.
2
3use 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/// Operational counters from one content-analysis pass.
24#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
25pub struct AnalysisReport {
26    /// Regular files considered by the requested profile.
27    pub candidates: u64,
28    /// Bytes actually returned by fresh file reads, including partial binary probes.
29    pub bytes_read: u64,
30    /// Wall time spent processing fresh candidates.
31    pub elapsed_ns: u64,
32    /// Results accepted by the index's conditional mutation boundary.
33    pub applied: u64,
34    /// Results discarded because indexed metadata changed while workers ran.
35    pub stale: u64,
36    /// Coverage of the shared lines unit.
37    pub lines: AnalyzerCoverage,
38    /// Coverage of the code unit when requested.
39    pub code: Option<AnalyzerCoverage>,
40    /// Coverage of the words unit when requested.
41    pub words: Option<AnalyzerCoverage>,
42}
43
44/// Operational and semantic outcomes for one requested analyzer unit.
45#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
46pub struct AnalyzerCoverage {
47    /// Files for which the unit produced metrics.
48    pub analyzed: u64,
49    /// Known or observed binary files.
50    pub binary: u64,
51    /// Files whose byte stream was not valid UTF-8.
52    pub invalid_utf8: u64,
53    /// Files with a recognized but unsupported text encoding.
54    pub unsupported_encoding: u64,
55    /// Files that changed during their read.
56    pub changed_during_read: u64,
57    /// File-open, metadata, or read failures.
58    pub io_errors: u64,
59    /// Files for which this analyzer was unavailable.
60    pub unsupported: u64,
61}
62
63impl AnalysisReport {
64    /// Whether content analysis completed without an operational failure.
65    ///
66    /// Binary data, invalid UTF-8, and unsupported analyzers are coverage outcomes. They
67    /// remain visible in roll-ups but do not mean the filesystem operation failed.
68    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    /// Explain operational failures without presenting expected coverage as an error.
76    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
113/// Analyze all regular files selected by `request` with a fixed-size worker pool.
114///
115/// Workers own immutable candidates and never retain an index borrow during I/O. The
116/// caller thread applies observations afterward, so metadata changes remain serialized
117/// through the index's own apply step, which is crate-private until the request model
118/// decides the public analysis surface.
119pub fn analyze_index(index: &mut Index, request: AnalysisRequest) -> AnalysisReport {
120    analyze_index_observed(index, request, None)
121}
122
123/// [`analyze_index`], reporting the pass through `progress` when one is attached.
124///
125/// The candidate total is known before the first file is read, so this is the one
126/// phase with an exact denominator. Each result is counted as it reaches the caller's
127/// thread, whether the index applies it or discards it as stale: the file was read
128/// either way, and progress counts work done. At return, `analysis` is
129/// `(candidates, candidates)`.
130pub(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    // Cloned out before the scope: the workers need the index's rules while the receive
160    // loop holds the index mutably, and an `Arc` is what lets both be true.
161    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            // Per result, on this thread, beside the index apply each result already
193            // costs: the workers never touch the handle.
194            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            // Word volume is meaningful for any text, and the accumulator has already
457            // counted it during the streaming read — zeroing it outside prose and markup
458            // discarded finished work and, with it, the answer to "how much text is in
459            // this tree", which is the cheap proxy for context-window sizing that agent
460            // consumers ask for.
461            //
462            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    /// Exercises many streaming chunks with a realistically large generated source file.
640    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    /// Every words-unit metric is independent of whether code was also requested.
672    #[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}