Skip to main content

git_slop/
inventory.rs

1use std::fs;
2use std::io::Read;
3use std::path::Path;
4use std::process::Command;
5
6use anyhow::{Context, Result};
7use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
8use serde_json::{Value, json};
9use sha2::{Digest, Sha256};
10
11use crate::config::{pointer_strings, pointer_u64};
12use crate::model::{Classification, InventoryFile, SkippedCounts};
13
14mod generated;
15use generated::{configured_generated_provenance, generated_provenance};
16
17const NULL_BYTE_WINDOW: usize = 4096;
18
19fn decode_text(raw: Vec<u8>) -> Option<String> {
20    if raw.starts_with(&[0xef, 0xbb, 0xbf]) {
21        return String::from_utf8(raw[3..].to_vec()).ok();
22    }
23    if raw.starts_with(&[0xff, 0xfe]) || raw.starts_with(&[0xfe, 0xff]) {
24        let little_endian = raw.starts_with(&[0xff, 0xfe]);
25        let bytes = &raw[2..];
26        if bytes.len() % 2 != 0 {
27            return None;
28        }
29        let units = bytes.chunks_exact(2).map(|pair| {
30            if little_endian {
31                u16::from_le_bytes([pair[0], pair[1]])
32            } else {
33                u16::from_be_bytes([pair[0], pair[1]])
34            }
35        });
36        return char::decode_utf16(units)
37            .collect::<Result<String, _>>()
38            .ok();
39    }
40    String::from_utf8(raw).ok()
41}
42
43fn looks_binary(raw: &[u8]) -> bool {
44    if raw.is_empty() || raw.starts_with(&[0xff, 0xfe]) || raw.starts_with(&[0xfe, 0xff]) {
45        return false;
46    }
47    let window = NULL_BYTE_WINDOW.min(raw.len());
48    let starts = [
49        0,
50        raw.len().saturating_sub(window) / 2,
51        raw.len().saturating_sub(window),
52    ];
53    let mut sampled = 0usize;
54    let mut suspicious = 0usize;
55    for start in starts {
56        for byte in &raw[start..(start + window).min(raw.len())] {
57            sampled += 1;
58            if *byte == 0 || *byte < 0x09 || matches!(*byte, 0x0b | 0x0c | 0x0e..=0x1f) {
59                suspicious += 1;
60            }
61        }
62    }
63    raw.iter().take(window).any(|byte| *byte == 0) || suspicious.saturating_mul(100) > sampled
64}
65
66fn ignore_set(patterns: &[String]) -> Result<GlobSet> {
67    let mut builder = GlobSetBuilder::new();
68    for pattern in patterns {
69        builder
70            .add(Glob::new(pattern).with_context(|| format!("invalid ignore glob {pattern:?}"))?);
71        if !pattern.contains('/') {
72            builder.add(
73                Glob::new(&format!("**/{pattern}"))
74                    .with_context(|| format!("invalid ignore glob {pattern:?}"))?,
75            );
76        }
77    }
78    Ok(builder.build()?)
79}
80
81fn language_for_path(path: &str) -> &'static str {
82    let lower = path.to_ascii_lowercase();
83    let extension = lower.rsplit('.').next().unwrap_or_default();
84    match extension {
85        "rs" => "Rust",
86        "py" | "pyi" => "Python",
87        "js" | "mjs" | "cjs" => "JavaScript",
88        "jsx" => "JSX",
89        "ts" | "mts" | "cts" => "TypeScript",
90        "tsx" => "TSX",
91        "swift" => "Swift",
92        "kt" | "kts" => "Kotlin",
93        "java" => "Java",
94        "go" => "Go",
95        "rb" => "Ruby",
96        "php" => "PHP",
97        "c" | "h" => "C",
98        "cc" | "cpp" | "cxx" | "hpp" => "C++",
99        "cs" => "C#",
100        "sh" | "bash" | "zsh" => "Shell",
101        "md" | "mdx" => "Markdown",
102        "json" | "jsonl" => "JSON",
103        "yaml" | "yml" => "YAML",
104        "toml" => "TOML",
105        "xml" => "XML",
106        "html" | "htm" => "HTML",
107        "css" | "scss" | "sass" | "less" => "CSS",
108        "sql" => "SQL",
109        "graphql" | "gql" => "GraphQL",
110        "csv" => "CSV",
111        "tsv" => "TSV",
112        "txt" | "text" => "Plain Text",
113        "svg" => "SVG",
114        _ => "Plain Text",
115    }
116}
117
118fn is_drizzle_metadata(path: &str) -> bool {
119    let Some((directory, name)) = path.rsplit_once('/') else {
120        return false;
121    };
122    if directory != "drizzle/meta" && !directory.ends_with("/drizzle/meta") {
123        return false;
124    }
125    name == "_journal.json"
126        || name.strip_suffix("_snapshot.json").is_some_and(|prefix| {
127            !prefix.is_empty() && prefix.bytes().all(|byte| byte.is_ascii_digit())
128        })
129}
130
131fn classification_for_path(path: &str) -> Classification {
132    let lower = path.to_ascii_lowercase();
133    let name = lower.rsplit('/').next().unwrap_or(&lower);
134    if lower.starts_with("vendor/")
135        || lower.contains("/vendor/")
136        || lower.starts_with("third_party/")
137        || lower.contains("/third_party/")
138        || lower.starts_with("node_modules/")
139    {
140        Classification::Vendored
141    } else if lower.starts_with("generated/")
142        || lower.contains("/generated/")
143        || lower.starts_with("dist/")
144        || name.ends_with(".generated.rs")
145        || name.ends_with(".generated.ts")
146        || name.ends_with(".generated.js")
147        || is_drizzle_metadata(&lower)
148    {
149        Classification::Generated
150    } else if lower.contains("/snapshots/")
151        || lower.contains("/__snapshots__/")
152        || lower.contains("/golden/")
153        || lower.starts_with("snapshots/")
154        || lower.starts_with("golden/")
155        || name.ends_with(".snap")
156    {
157        Classification::Snapshot
158    } else if lower.starts_with("fixtures/")
159        || lower.contains("/fixtures/")
160        || lower.starts_with("testdata/")
161        || lower.contains("/testdata/")
162        || name.contains("fixture")
163    {
164        Classification::Fixture
165    } else if (lower.contains("/migrations/") || lower.starts_with("migrations/"))
166        && (lower.contains("fixture") || lower.contains("test"))
167    {
168        Classification::MigrationFixture
169    } else if lower.starts_with("tests/")
170        || lower.starts_with("test/")
171        || lower.contains("/tests/")
172        || lower.contains("/test/")
173        || name.contains(".test.")
174        || name.contains("_test.")
175        || name.starts_with("test_")
176        || name == "tests.rs"
177        || lower.contains("__tests__")
178    {
179        Classification::Test
180    } else if lower.starts_with(".github/workflows/")
181        || lower == "action.yml"
182        || lower == "action.yaml"
183    {
184        Classification::Workflow
185    } else if lower.starts_with(".github/issue_template/")
186        || lower == ".github/funding.yml"
187        || lower.starts_with("schemas/")
188        || (lower.starts_with("plugins/")
189            && matches!(
190                name,
191                "plugin.json" | "marketplace.json" | "marketplace-source.json"
192            ))
193        || lower.starts_with(".agents/plugins/")
194        || lower.starts_with(".codex-plugin/")
195    {
196        Classification::Config
197    } else if lower.starts_with("man/") || name.ends_with(".1") {
198        Classification::Generated
199    } else if lower.starts_with("docs/") || lower.ends_with(".md") || lower.ends_with(".mdx") {
200        Classification::Docs
201    } else if lower.starts_with("action/")
202        || lower.starts_with("scripts/")
203        || lower.starts_with("tools/")
204        || lower.starts_with(".github/actions/")
205    {
206        Classification::Tool
207    } else if lower.starts_with("config/")
208        || matches!(
209            name,
210            "cargo.toml" | "pyproject.toml" | "package.json" | "tsconfig.json" | "wrangler.toml"
211        )
212    {
213        Classification::Config
214    } else if lower.starts_with("src/")
215        || lower.starts_with("xtask/src/")
216        || lower.starts_with("app/")
217        || lower.starts_with("lib/")
218        || lower.starts_with("crates/")
219        || lower.starts_with("packages/")
220    {
221        Classification::Source
222    } else {
223        Classification::Other
224    }
225}
226
227fn has_generated_marker(text: &str) -> bool {
228    text.lines().take(3).any(|line| {
229        let normalized = line.trim().to_ascii_lowercase();
230        normalized.starts_with("# @generated")
231            || normalized.starts_with("// @generated")
232            || normalized.starts_with("/* @generated")
233    })
234}
235
236fn line_counts(text: &str, language: &str) -> (usize, usize, usize, usize) {
237    if text.is_empty() {
238        return (0, 0, 0, 0);
239    }
240    let lines: Vec<&str> = text.lines().collect();
241    let mut blank = 0;
242    let mut comments = 0;
243    let mut code = 0;
244    let mut in_block_comment = false;
245    for line in &lines {
246        let trimmed = line.trim();
247        if trimmed.is_empty() {
248            blank += 1;
249            continue;
250        }
251        if in_block_comment {
252            comments += 1;
253            if trimmed.contains("*/") {
254                in_block_comment = false;
255            }
256            continue;
257        }
258        let line_comment = match language {
259            "Python" | "Ruby" | "Shell" | "YAML" | "TOML" => trimmed.starts_with('#'),
260            "Markdown" => trimmed.starts_with("<!--"),
261            _ => trimmed.starts_with("//"),
262        };
263        if line_comment {
264            comments += 1;
265        } else if trimmed.starts_with("/*") {
266            comments += 1;
267            in_block_comment = !trimmed.contains("*/");
268        } else {
269            code += 1;
270        }
271    }
272    (lines.len(), code, comments, blank)
273}
274
275fn profile_for(path: &str, bytes: usize, config: &Value) -> &'static str {
276    let lower = path.to_ascii_lowercase();
277    let data_extension = matches!(
278        lower.rsplit('.').next().unwrap_or_default(),
279        "csv" | "tsv" | "parquet" | "ndjson" | "jsonl" | "sqlite" | "db" | "xml" | "json"
280    );
281    let data_path = lower.starts_with("data/")
282        || lower.contains("/data/")
283        || lower.contains("fixtures/")
284        || lower.contains("reference_data/");
285    let min_bytes = pointer_u64(config, "/health/data_context_min_bytes", 262_144) as usize;
286    if data_extension && (data_path || bytes >= min_bytes) {
287        "data_context"
288    } else {
289        "agent_context"
290    }
291}
292
293pub(super) struct CompiledPathOverride {
294    matcher: GlobMatcher,
295    classification: Option<String>,
296    profile: Option<String>,
297    language: Option<String>,
298    pub(super) generated_source_globs: Vec<String>,
299    pub(super) generator_command: Option<String>,
300    pub(super) verification_command: Option<String>,
301}
302
303fn compile_path_overrides(config: &Value) -> Result<Vec<CompiledPathOverride>> {
304    config
305        .pointer("/inventory/path_overrides")
306        .and_then(Value::as_array)
307        .into_iter()
308        .flatten()
309        .map(|mapping| {
310            let pattern = mapping
311                .get("glob")
312                .and_then(Value::as_str)
313                .context("inventory.path_overrides entry is missing glob")?;
314            Ok(CompiledPathOverride {
315                matcher: Glob::new(pattern)
316                    .with_context(|| format!("invalid path override glob {pattern:?}"))?
317                    .compile_matcher(),
318                classification: mapping
319                    .get("classification")
320                    .and_then(Value::as_str)
321                    .map(str::to_owned),
322                profile: mapping
323                    .get("profile")
324                    .and_then(Value::as_str)
325                    .map(str::to_owned),
326                language: mapping
327                    .get("language")
328                    .and_then(Value::as_str)
329                    .map(str::to_owned),
330                generated_source_globs: mapping
331                    .get("generated_source_globs")
332                    .and_then(Value::as_array)
333                    .into_iter()
334                    .flatten()
335                    .filter_map(Value::as_str)
336                    .map(str::to_owned)
337                    .collect(),
338                generator_command: mapping
339                    .get("generator_command")
340                    .and_then(Value::as_str)
341                    .map(str::to_owned),
342                verification_command: mapping
343                    .get("verification_command")
344                    .and_then(Value::as_str)
345                    .map(str::to_owned),
346            })
347        })
348        .collect()
349}
350
351fn path_override(
352    path: &str,
353    overrides: &[CompiledPathOverride],
354) -> (Option<String>, Option<String>, Option<String>) {
355    let mut classification = None;
356    let mut profile = None;
357    let mut language = None;
358    for mapping in overrides {
359        if mapping.matcher.is_match(path) {
360            classification.clone_from(&mapping.classification);
361            profile.clone_from(&mapping.profile);
362            language.clone_from(&mapping.language);
363        }
364    }
365    (classification, profile, language)
366}
367
368fn sha256_bytes(raw: &[u8]) -> String {
369    hex::encode(Sha256::digest(raw))
370}
371
372fn sha256_file(path: &Path) -> Result<String> {
373    let mut file = fs::File::open(path)
374        .with_context(|| format!("failed to open {} for hashing", path.display()))?;
375    let mut digest = Sha256::new();
376    let mut buffer = [0u8; 64 * 1024];
377    loop {
378        let read = file
379            .read(&mut buffer)
380            .with_context(|| format!("failed to hash {}", path.display()))?;
381        if read == 0 {
382            break;
383        }
384        digest.update(&buffer[..read]);
385    }
386    Ok(hex::encode(digest.finalize()))
387}
388
389fn tracked_index_sha256(repo_root: &Path, path: &str, reason: &str) -> String {
390    let index_spec = format!(":{path}");
391    if let Ok(output) = Command::new("git")
392        .args(["show", "--no-ext-diff", &index_spec])
393        .current_dir(repo_root)
394        .output()
395    {
396        if output.status.success() {
397            return sha256_bytes(&output.stdout);
398        }
399    }
400    if let Ok(output) = Command::new("git")
401        .args(["ls-files", "--stage", "--", path])
402        .current_dir(repo_root)
403        .output()
404    {
405        if output.status.success() && !output.stdout.is_empty() {
406            return sha256_bytes(&output.stdout);
407        }
408    }
409    // This branch is reachable only for inconsistent callers (for example a
410    // unit-test fixture that names an untracked missing path). Keep the field
411    // structurally valid while making the absence explicit in its preimage.
412    sha256_bytes(format!("unavailable:{reason}:{path}").as_bytes())
413}
414
415fn skipped_record(
416    path: &str,
417    bytes: usize,
418    reason: &str,
419    content_sha256: String,
420    config: &Value,
421    overrides: &[CompiledPathOverride],
422) -> InventoryFile {
423    let (classification_override, profile_override, language_override) =
424        path_override(path, overrides);
425    InventoryFile {
426        path: path.replace('\\', "/"),
427        bytes,
428        lines: 0,
429        blank_lines: 0,
430        code_lines: 0,
431        comment_lines: 0,
432        language: language_override.unwrap_or_else(|| language_for_path(path).into()),
433        profile: profile_override.unwrap_or_else(|| profile_for(path, bytes, config).to_string()),
434        classification: classification_override
435            .unwrap_or_else(|| classification_for_path(path).as_str().to_string()),
436        generated_from: Vec::new(),
437        generated_provenance: json!({"source_paths": [], "source_globs": [], "generator_command": null, "verification_command": null}),
438        content_sha256,
439        text: String::new(),
440        analysis_status: "skipped".to_string(),
441        skipped_reason: Some(reason.to_string()),
442        symlink_metadata: None,
443    }
444}
445
446pub fn build(
447    repo_root: &Path,
448    tracked_paths: &[String],
449    config: &Value,
450) -> Result<(Vec<InventoryFile>, SkippedCounts)> {
451    let patterns = pointer_strings(config, "/inventory/ignore_globs");
452    let ignored = ignore_set(&patterns)?;
453    let path_overrides = compile_path_overrides(config)?;
454    let mut skipped = SkippedCounts::default();
455    let mut records = Vec::new();
456    let large_file_bytes = pointer_u64(config, "/resources/large_file_bytes", 2_097_152) as usize;
457    for relative_path in tracked_paths {
458        if ignored.is_match(relative_path) {
459            skipped.ignored += 1;
460            continue;
461        }
462        let absolute_path = repo_root.join(relative_path);
463        let metadata = match fs::symlink_metadata(&absolute_path) {
464            Ok(metadata) => metadata,
465            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
466                skipped.missing += 1;
467                records.push(skipped_record(
468                    relative_path,
469                    0,
470                    "missing",
471                    tracked_index_sha256(repo_root, relative_path, "missing"),
472                    config,
473                    &path_overrides,
474                ));
475                continue;
476            }
477            Err(error) => {
478                return Err(error)
479                    .with_context(|| format!("failed to inspect {}", absolute_path.display()));
480            }
481        };
482        // Git represents a submodule as a tracked gitlink whose worktree path
483        // is a directory. It is repository metadata, not a text file owned by
484        // this analyzer.
485        if metadata.is_dir() {
486            skipped.ignored += 1;
487            records.push(skipped_record(
488                relative_path,
489                0,
490                "gitlink",
491                tracked_index_sha256(repo_root, relative_path, "gitlink"),
492                config,
493                &path_overrides,
494            ));
495            continue;
496        }
497        if !metadata.file_type().is_symlink() && metadata.len() > large_file_bytes as u64 {
498            let bytes = usize::try_from(metadata.len()).unwrap_or(usize::MAX);
499            let content_sha256 = sha256_file(&absolute_path)?;
500            records.push(skipped_record(
501                relative_path,
502                bytes,
503                "large_file_limit",
504                content_sha256,
505                config,
506                &path_overrides,
507            ));
508            continue;
509        }
510        // Analyze the link stored by Git, never the target it happens to resolve to
511        // on the current machine. Following a tracked symlink could otherwise read
512        // arbitrary content outside the repository.
513        let raw = if metadata.file_type().is_symlink() {
514            fs::read_link(&absolute_path)
515                .with_context(|| format!("failed to read link {}", absolute_path.display()))?
516                .to_string_lossy()
517                .into_owned()
518                .into_bytes()
519        } else {
520            fs::read(&absolute_path)
521                .with_context(|| format!("failed to read {}", absolute_path.display()))?
522        };
523        let content_sha256 = sha256_bytes(&raw);
524        if looks_binary(&raw) {
525            skipped.binary += 1;
526            records.push(skipped_record(
527                relative_path,
528                raw.len(),
529                "binary",
530                content_sha256,
531                config,
532                &path_overrides,
533            ));
534            continue;
535        }
536        let bytes = raw.len();
537        let Some(mut text) = decode_text(raw) else {
538            skipped.undecodable += 1;
539            records.push(skipped_record(
540                relative_path,
541                bytes,
542                "undecodable",
543                content_sha256,
544                config,
545                &path_overrides,
546            ));
547            continue;
548        };
549        if !metadata.file_type().is_symlink() && text.contains("\r\n") {
550            text = text.replace("\r\n", "\n");
551        }
552        let (classification_override, profile_override, language_override) =
553            path_override(relative_path, &path_overrides);
554        let language = language_override.unwrap_or_else(|| language_for_path(relative_path).into());
555        let (lines, code_lines, comment_lines, blank_lines) = line_counts(&text, &language);
556        let (generated_from, generated_provenance) =
557            configured_generated_provenance(relative_path, &path_overrides, tracked_paths)
558                .unwrap_or_else(|| generated_provenance(&text, tracked_paths));
559        records.push(InventoryFile {
560            path: relative_path.replace('\\', "/"),
561            bytes,
562            lines,
563            blank_lines,
564            code_lines,
565            comment_lines,
566            language,
567            profile: profile_override
568                .unwrap_or_else(|| profile_for(relative_path, bytes, config).to_string()),
569            classification: classification_override.unwrap_or_else(|| {
570                if has_generated_marker(&text) {
571                    "generated".to_string()
572                } else {
573                    classification_for_path(relative_path).as_str().to_string()
574                }
575            }),
576            generated_from,
577            generated_provenance,
578            content_sha256,
579            text,
580            analysis_status: "analyzed".to_string(),
581            skipped_reason: None,
582            symlink_metadata: metadata.file_type().is_symlink().then(|| {
583                json!({
584                    "kind": "symbolic_link",
585                    "target_status": if absolute_path.exists() { "resolves" } else { "broken" },
586                    "target_content_read": false
587                })
588            }),
589        });
590    }
591    records.sort_by(|left, right| left.path.cmp(&right.path));
592    Ok((records, skipped))
593}
594
595#[cfg(test)]
596mod tests {
597    use std::fs;
598    #[cfg(unix)]
599    use std::os::unix::fs::symlink;
600
601    use serde_json::json;
602    use tempfile::tempdir;
603
604    use super::build;
605    use crate::config;
606
607    #[cfg(unix)]
608    #[test]
609    fn tracked_symlinks_are_analyzed_without_following_their_targets() {
610        let repository = tempdir().expect("repository");
611        let outside = tempdir().expect("outside");
612        let secret = outside.path().join("secret.txt");
613        fs::write(&secret, "do not read this target").expect("secret");
614        symlink(&secret, repository.path().join("linked.txt")).expect("symlink");
615
616        let (files, skipped) = build(
617            repository.path(),
618            &["linked.txt".to_string()],
619            &config::default_config(),
620        )
621        .expect("inventory");
622
623        assert_eq!(files.len(), 1);
624        assert_eq!(files[0].text, secret.to_string_lossy());
625        assert!(!files[0].text.contains("do not read this target"));
626        assert_eq!(skipped.missing, 0);
627    }
628
629    #[test]
630    fn tracked_gitlink_directories_are_skipped_instead_of_read_as_files() {
631        let repository = tempdir().expect("repository");
632        fs::create_dir_all(repository.path().join("vendor/submodule")).expect("gitlink directory");
633
634        let (files, skipped) = build(
635            repository.path(),
636            &["vendor/submodule".to_string()],
637            &config::default_config(),
638        )
639        .expect("inventory");
640
641        assert_eq!(files.len(), 1);
642        assert_eq!(files[0].analysis_status, "skipped");
643        assert_eq!(files[0].skipped_reason.as_deref(), Some("gitlink"));
644        assert_eq!(skipped.ignored, 1);
645    }
646
647    #[test]
648    fn utf8_bom_and_utf16_bom_text_are_decoded_instead_of_marked_binary() {
649        let repository = tempdir().expect("repository");
650        fs::write(repository.path().join("utf8.txt"), b"\xef\xbb\xbfhello\n").expect("utf8 bom");
651        fs::write(
652            repository.path().join("utf16.txt"),
653            [0xff, 0xfe, b'h', 0, b'i', 0, b'\n', 0],
654        )
655        .expect("utf16 bom");
656        let (files, skipped) = build(
657            repository.path(),
658            &["utf8.txt".to_string(), "utf16.txt".to_string()],
659            &config::default_config(),
660        )
661        .expect("inventory");
662        assert_eq!(files.len(), 2);
663        let decoded = files
664            .iter()
665            .map(|file| (file.path.as_str(), file.text.as_str()))
666            .collect::<std::collections::BTreeMap<_, _>>();
667        assert_eq!(decoded["utf8.txt"], "hello\n");
668        assert_eq!(decoded["utf16.txt"], "hi\n");
669        assert_eq!(skipped.binary, 0);
670        assert_eq!(skipped.undecodable, 0);
671    }
672
673    #[test]
674    fn tracked_text_normalizes_crlf_for_cross_platform_analysis() {
675        let repository = tempdir().expect("repository");
676        fs::write(
677            repository.path().join("source.rs"),
678            b"fn one() {}\r\nfn two() {}\r\n",
679        )
680        .expect("crlf source");
681        let (files, skipped) = build(
682            repository.path(),
683            &["source.rs".to_string()],
684            &config::default_config(),
685        )
686        .expect("inventory");
687        assert_eq!(skipped.binary, 0);
688        assert_eq!(files[0].text, "fn one() {}\nfn two() {}\n");
689        assert_eq!(files[0].bytes, 26);
690    }
691
692    #[test]
693    fn explicit_generated_markers_override_ordinary_source_paths() {
694        let repository = tempdir().expect("repository");
695        fs::write(
696            repository.path().join("release.yml"),
697            "# @generated from reviewed stage fragments\nname: Release\n",
698        )
699        .expect("generated workflow");
700        let (files, _) = build(
701            repository.path(),
702            &["release.yml".to_string()],
703            &config::default_config(),
704        )
705        .expect("inventory");
706        assert_eq!(files[0].classification, "generated");
707        assert_eq!(
708            files[0].generated_from,
709            vec!["reviewed stage fragments".to_string()]
710        );
711    }
712
713    #[test]
714    fn path_overrides_describe_commentless_generated_files_structurally() {
715        let repository = tempdir().expect("repository");
716        fs::create_dir_all(repository.path().join("schemas")).expect("schema directory");
717        fs::create_dir_all(repository.path().join("src")).expect("source directory");
718        fs::write(repository.path().join("schemas/report.json"), "{}\n").expect("report schema");
719        fs::write(
720            repository.path().join("src/contract.rs"),
721            "fn schema() {}\n",
722        )
723        .expect("contract source");
724        let tracked = vec![
725            "schemas/report.json".to_string(),
726            "src/contract.rs".to_string(),
727        ];
728        let mut config = config::default_config();
729        config["inventory"]["path_overrides"] = json!([{
730            "glob": "schemas/report.json",
731            "classification": "generated",
732            "generated_source_globs": ["src/*.rs"],
733            "generator_command": "git slop schema report --output schemas/report.json",
734            "verification_command": "cargo test published_report_schema"
735        }]);
736        let (files, _) = build(repository.path(), &tracked, &config).expect("inventory");
737        let report = files
738            .iter()
739            .find(|file| file.path == "schemas/report.json")
740            .expect("generated report");
741        assert_eq!(report.classification, "generated");
742        assert_eq!(report.generated_from, vec!["src/contract.rs"]);
743        assert_eq!(
744            report.generated_provenance["verification_command"],
745            "cargo test published_report_schema"
746        );
747    }
748
749    #[test]
750    fn drizzle_metadata_is_generated_without_reclassifying_neighboring_files() {
751        let repository = tempdir().expect("repository");
752        let cases = [
753            ("drizzle/meta/0000_snapshot.json", "generated"),
754            ("drizzle/meta/_journal.json", "generated"),
755            (
756                "packages/example-db/drizzle/meta/0001_snapshot.json",
757                "generated",
758            ),
759            (
760                "packages/example-db/drizzle/meta/_journal.json",
761                "generated",
762            ),
763            ("packages/example-db/src/0001_snapshot.json", "source"),
764            ("packages/example-db/src/customer_snapshot.json", "source"),
765            (
766                "packages/example-db/drizzle/meta/customer_snapshot.json",
767                "source",
768            ),
769            ("packages/example-db/drizzle/meta/_snapshot.json", "source"),
770            (
771                "packages/example-db/drizzle/meta/nested/0001_snapshot.json",
772                "source",
773            ),
774            (
775                "packages/example-db/not-drizzle/meta/0001_snapshot.json",
776                "source",
777            ),
778            (
779                "packages/example-db/drizzle/metadata/0001_snapshot.json",
780                "source",
781            ),
782            ("packages/example-db/meta/_journal.json", "source"),
783            ("packages/example-db/drizzle/0001_migration.sql", "source"),
784            ("vendor/drizzle/meta/0001_snapshot.json", "vendored"),
785        ];
786        let tracked = cases
787            .iter()
788            .map(|(path, _)| path.to_string())
789            .collect::<Vec<_>>();
790        for path in &tracked {
791            let absolute = repository.path().join(path);
792            fs::create_dir_all(absolute.parent().expect("parent")).expect("directory");
793            fs::write(absolute, "{}\n").expect("commentless metadata");
794        }
795        let (files, _) =
796            build(repository.path(), &tracked, &config::default_config()).expect("inventory");
797        for (path, expected) in cases {
798            let file = files.iter().find(|file| file.path == path).expect("file");
799            assert_eq!(file.classification, expected, "{path}");
800        }
801
802        let mut config = config::default_config();
803        config["inventory"]["path_overrides"] = json!([{
804            "glob": "packages/example-db/drizzle/meta/*.json",
805            "classification": "source"
806        }]);
807        let (files, _) = build(repository.path(), &tracked, &config).expect("override inventory");
808        let snapshot = files
809            .iter()
810            .find(|file| file.path == "packages/example-db/drizzle/meta/0001_snapshot.json")
811            .expect("overridden snapshot");
812        assert_eq!(snapshot.classification, "source");
813    }
814
815    #[test]
816    fn golden_report_fixtures_are_not_classified_as_actionable_tests() {
817        let repository = tempdir().expect("repository");
818        fs::create_dir_all(repository.path().join("tests/fixtures/reports")).expect("fixture dir");
819        fs::write(
820            repository.path().join("tests/fixtures/reports/large.json"),
821            "{\"fixture\":true}\n",
822        )
823        .expect("fixture");
824        let (files, _) = build(
825            repository.path(),
826            &["tests/fixtures/reports/large.json".to_string()],
827            &config::default_config(),
828        )
829        .expect("inventory");
830        assert_eq!(files[0].classification, "fixture");
831    }
832}