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 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 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 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}