1use anyhow::{bail, Context, Result};
14use std::env;
15use std::fs;
16use std::path::{Path, PathBuf};
17
18const LOWERING_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23fn wrapper_fingerprint() -> u64 {
30 use std::sync::OnceLock;
31 static FP: OnceLock<u64> = OnceLock::new();
32 *FP.get_or_init(|| {
33 let Ok(exe) = env::current_exe() else {
34 return 0;
35 };
36 let Ok(meta) = fs::metadata(&exe) else {
37 return 0;
38 };
39 let mtime = meta
40 .modified()
41 .ok()
42 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
43 .map(|d| d.as_nanos() as u64)
44 .unwrap_or(0);
45 meta.len() ^ mtime
46 })
47}
48
49pub fn source_cache_key(source: &str) -> u64 {
53 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
54 const FNV_PRIME: u64 = 0x100000001b3;
55 let mut hash = FNV_OFFSET;
56 for byte in LOWERING_VERSION
57 .bytes()
58 .chain(wrapper_fingerprint().to_le_bytes())
59 .chain(source.bytes())
60 {
61 hash ^= byte as u64;
62 hash = hash.wrapping_mul(FNV_PRIME);
63 }
64 hash
65}
66
67pub struct Prepared {
72 pub lowered_root: PathBuf,
73 pub remap_flag: String,
74}
75
76pub fn collect_crate_callees(src_dir: &Path) -> Vec<(String, Vec<String>)> {
97 use std::collections::{HashMap, HashSet};
98 let mut sigs: HashMap<String, Vec<String>> = HashMap::new();
99 let mut ambiguous: HashSet<String> = HashSet::new();
100 let mut visited: HashSet<PathBuf> = HashSet::new();
101 collect_crate_callees_recursive(src_dir, &mut sigs, &mut ambiguous, &mut visited);
102 let mut out: Vec<(String, Vec<String>)> = sigs.into_iter().collect();
103 out.sort_by(|a, b| a.0.cmp(&b.0));
104 out
105}
106
107fn collect_crate_callees_recursive(
108 dir: &Path,
109 sigs: &mut std::collections::HashMap<String, Vec<String>>,
110 ambiguous: &mut std::collections::HashSet<String>,
111 visited: &mut std::collections::HashSet<PathBuf>,
112) {
113 if !dir.is_dir() {
114 return;
115 }
116 let Ok(read) = fs::read_dir(dir) else {
117 return;
118 };
119 for entry in read.flatten() {
120 let path = entry.path();
121 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
122 if !visited.insert(canonical) {
123 continue;
124 }
125 if path.is_dir() {
126 collect_crate_callees_recursive(&path, sigs, ambiguous, visited);
127 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
128 if let Ok(source) = fs::read_to_string(&path) {
129 let file = syn::parse_file(&source).ok().or_else(|| {
135 trust_lower::lower(&source)
136 .ok()
137 .and_then(|lo| syn::parse_file(&lo.source).ok())
138 });
139 if let Some(file) = file {
140 walk_items_for_sigs(&file.items, sigs, ambiguous);
141 }
142 }
143 }
144 }
145}
146
147fn walk_items_for_sigs(
148 items: &[syn::Item],
149 sigs: &mut std::collections::HashMap<String, Vec<String>>,
150 ambiguous: &mut std::collections::HashSet<String>,
151) {
152 for item in items {
153 match item {
154 syn::Item::Fn(f) => record_fn_sig(&f.sig, sigs, ambiguous),
155 syn::Item::Mod(m) => {
156 if let Some((_, inner)) = &m.content {
157 walk_items_for_sigs(inner, sigs, ambiguous);
158 }
159 }
160 _ => {}
161 }
162 }
163}
164
165fn record_fn_sig(
166 sig: &syn::Signature,
167 sigs: &mut std::collections::HashMap<String, Vec<String>>,
168 ambiguous: &mut std::collections::HashSet<String>,
169) {
170 let name = sig.ident.to_string();
171 if ambiguous.contains(&name) {
172 return;
173 }
174 let mut params: Vec<String> = Vec::new();
175 for input in &sig.inputs {
176 match input {
177 syn::FnArg::Receiver(_) => {} syn::FnArg::Typed(pat_type) => match &*pat_type.pat {
179 syn::Pat::Ident(pi) => params.push(pi.ident.to_string()),
180 _ => {
181 sigs.remove(&name);
183 ambiguous.insert(name);
184 return;
185 }
186 },
187 }
188 }
189 match sigs.get(&name) {
190 Some(existing) if existing != ¶ms => {
191 sigs.remove(&name);
192 ambiguous.insert(name);
193 }
194 Some(_) => {}
195 None => {
196 sigs.insert(name, params);
197 }
198 }
199}
200
201pub fn find_input_rs(args: &[String]) -> Option<usize> {
206 args.iter().enumerate().find_map(|(i, a)| {
207 if a == "-" {
208 return None;
209 }
210 if a.ends_with(".rs") && !a.starts_with('-') {
211 Some(i)
212 } else {
213 None
214 }
215 })
216}
217
218pub fn crate_is_force_strict() -> bool {
228 force_strict_for(
229 env::var("TRUST_STRICT_PACKAGES").ok().as_deref(),
230 env::var("CARGO_PKG_NAME").ok().as_deref(),
231 )
232}
233
234fn force_strict_for(pkgs: Option<&str>, name: Option<&str>) -> bool {
239 let (Some(pkgs), Some(name)) = (pkgs, name) else {
240 return false;
241 };
242 let name = name.trim();
243 !name.is_empty() && pkgs.split(',').any(|p| p.trim() == name)
244}
245
246fn should_lower(source: &str) -> bool {
249 trust_lower::is_strict_source(source) || crate_is_force_strict()
250}
251
252pub fn prepare_strict_input(input_path: &Path) -> Result<Option<Prepared>> {
259 let source = match fs::read_to_string(input_path) {
260 Ok(s) => s,
261 Err(_) => return Ok(None),
262 };
263
264 if !should_lower(&source) {
265 return Ok(None);
266 }
267 let force_strict = crate_is_force_strict();
268
269 let file_name = input_path
270 .file_name()
271 .context("input path has no file name")?;
272
273 let cache_key = source_cache_key(&source);
274 let cache_root = env::temp_dir().join("trust-cache");
275 let cache_dir = cache_root.join(format!("{cache_key:016x}"));
276 let cached_file = cache_dir.join(file_name);
277
278 if !cache_dir.exists() {
285 let staging = cache_root.join(format!(".staging-{cache_key:016x}-{}", std::process::id()));
286 let _ = fs::remove_dir_all(&staging);
287
288 let result = (|| -> Result<()> {
289 let src_dir = input_path
290 .parent()
291 .filter(|p| !p.as_os_str().is_empty())
292 .map(Path::to_path_buf)
293 .unwrap_or_else(|| PathBuf::from("."));
294
295 let crate_extras = collect_crate_callees(&src_dir);
300
301 let dep_extras = trust_lower::sig_index::load_from_env();
310 let extras = trust_lower::sig_index::merge(&[crate_extras, dep_extras]);
311
312 let mut visited = std::collections::HashSet::new();
313 mirror_module_tree_with_extras(&src_dir, &staging, &mut visited, &extras)
314 .with_context(|| format!("mirroring src tree from {}", src_dir.display()))?;
315
316 if !staging.join(file_name).exists() {
319 let out =
320 trust_lower::lower_with_extra_callees_forced(&source, &extras, force_strict)
321 .with_context(|| format!("lowering {}", input_path.display()))?;
322 emit_diagnostics(&out, &source, input_path)?;
323 fs::create_dir_all(&staging)?;
324 fs::write(staging.join(file_name), &out.source)?;
325 }
326 Ok(())
327 })();
328
329 if let Err(e) = result {
330 let _ = fs::remove_dir_all(&staging);
331 return Err(e);
332 }
333
334 if fs::rename(&staging, &cache_dir).is_err() {
337 let _ = fs::remove_dir_all(&staging);
338 if !cache_dir.exists() {
339 bail!(
340 "could not publish lowering cache at {}",
341 cache_dir.display()
342 );
343 }
344 }
345 }
346
347 let parent = input_path
348 .parent()
349 .filter(|p| !p.as_os_str().is_empty())
350 .map(Path::to_path_buf)
351 .unwrap_or_else(|| PathBuf::from("."));
352
353 Ok(Some(Prepared {
354 lowered_root: cached_file,
355 remap_flag: format!(
356 "--remap-path-prefix={}={}",
357 cache_dir.display(),
358 parent.display()
359 ),
360 }))
361}
362
363fn emit_diagnostics(
364 out: &trust_lower::LowerOutput,
365 original_source: &str,
366 path: &Path,
367) -> Result<()> {
368 emit_diagnostics_to(out, original_source, path, &mut std::io::stderr())
369}
370
371fn message_format_is_json() -> bool {
378 env::var("TRUST_MESSAGE_FORMAT").is_ok_and(|v| v == "json")
379}
380
381fn emit_diagnostics_to(
387 out: &trust_lower::LowerOutput,
388 original_source: &str,
389 path: &Path,
390 writer: &mut impl std::io::Write,
391) -> Result<()> {
392 let mut diagnostics = out.diagnostics.clone();
400 if out.strict_mode {
401 let file: syn::File = syn::parse_str(&out.lint_source)
405 .with_context(|| format!("re-parsing lowered source from {}", path.display()))?;
406 diagnostics.extend(trust_lints::lint_strict(&file, original_source, true).diagnostics);
407 }
408
409 if message_format_is_json() {
410 let name = path.display().to_string();
413 let doc = trust_diag::to_json(
414 &diagnostics,
415 trust_diag::NamedSource {
416 name: &name,
417 text: original_source,
418 },
419 );
420 write!(writer, "{doc}")?;
421 if !doc.ends_with('\n') {
422 writeln!(writer)?;
423 }
424 } else {
425 for diag in &diagnostics {
426 writeln!(
427 writer,
428 "[{}] {}: {}",
429 diag.rule,
430 if diag.is_error() { "error" } else { "warning" },
431 diag.message
432 )?;
433 }
434 }
435 if diagnostics.iter().any(|d| d.is_error()) {
436 bail!("trust check failed on {}", path.display());
437 }
438 Ok(())
439}
440
441pub fn collect_test_only_files(src_dir: &Path) -> std::collections::HashSet<PathBuf> {
456 use std::collections::HashSet;
457 let mut all_files: Vec<PathBuf> = Vec::new();
458 collect_rs_files(src_dir, &mut all_files);
459
460 let mut decls: Vec<(PathBuf, String, bool)> = Vec::new();
462 for file in &all_files {
463 let Ok(source) = fs::read_to_string(file) else {
464 continue;
465 };
466 let Ok(tokens) = source.parse::<proc_macro2::TokenStream>() else {
467 continue;
468 };
469 for (name, is_test) in file_mod_declarations(&tokens) {
470 decls.push((file.clone(), name, is_test));
471 }
472 }
473
474 let resolve = |declaring: &Path, name: &str| -> Option<PathBuf> {
475 let dir = declaring.parent()?;
476 let flat = dir.join(format!("{name}.rs"));
477 if flat.is_file() {
478 return flat.canonicalize().ok();
479 }
480 let nested = dir.join(name).join("mod.rs");
481 if nested.is_file() {
482 return nested.canonicalize().ok();
483 }
484 None
485 };
486
487 let mut test_only: HashSet<PathBuf> = HashSet::new();
488 loop {
491 let mut grew = false;
492 for (declaring, name, is_test) in &decls {
493 let from_test_file = declaring
494 .canonicalize()
495 .map(|c| test_only.contains(&c))
496 .unwrap_or(false);
497 if !is_test && !from_test_file {
498 continue;
499 }
500 if let Some(target) = resolve(declaring, name) {
501 grew |= test_only.insert(target);
502 }
503 }
504 if !grew {
505 break;
506 }
507 }
508 test_only
509}
510
511fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
512 let Ok(read) = fs::read_dir(dir) else {
513 return;
514 };
515 for entry in read.flatten() {
516 let path = entry.path();
517 if path.is_dir() {
518 collect_rs_files(&path, out);
519 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
520 out.push(path);
521 }
522 }
523}
524
525fn cfg_args_positively_test(tokens: &proc_macro2::TokenStream) -> bool {
532 use proc_macro2::{Delimiter, TokenTree};
533 let trees: Vec<TokenTree> = tokens.clone().into_iter().collect();
534 let mut i = 0;
535 while i < trees.len() {
536 match &trees[i] {
537 TokenTree::Ident(id) if *id == "not" => {
538 if matches!(trees.get(i + 1), Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Parenthesis)
540 {
541 i += 2;
542 continue;
543 }
544 i += 1;
545 }
546 TokenTree::Ident(id) if *id == "any" || *id == "all" => {
547 if let Some(TokenTree::Group(g)) = trees.get(i + 1) {
548 if g.delimiter() == Delimiter::Parenthesis
549 && cfg_args_positively_test(&g.stream())
550 {
551 return true;
552 }
553 i += 2;
554 continue;
555 }
556 i += 1;
557 }
558 TokenTree::Ident(id) if *id == "test" => {
562 let followed_by_eq = matches!(
563 trees.get(i + 1),
564 Some(TokenTree::Punct(p)) if p.as_char() == '='
565 );
566 if !followed_by_eq {
567 return true;
568 }
569 i += 1;
570 }
571 _ => i += 1,
572 }
573 }
574 false
575}
576
577fn file_mod_declarations(tokens: &proc_macro2::TokenStream) -> Vec<(String, bool)> {
580 use proc_macro2::{Delimiter, TokenTree};
581 let trees: Vec<TokenTree> = tokens.clone().into_iter().collect();
582 let mut out = Vec::new();
583 let mut i = 0;
584 let mut pending_cfg_test = false;
585 while i < trees.len() {
586 match &trees[i] {
587 TokenTree::Punct(p) if p.as_char() == '#' => {
589 if let Some(TokenTree::Group(g)) = trees.get(i + 1) {
590 if g.delimiter() == Delimiter::Bracket {
591 let inner: Vec<TokenTree> = g.stream().into_iter().collect();
592 if let [TokenTree::Ident(name), TokenTree::Group(args)] = inner.as_slice() {
593 if *name == "cfg" {
594 pending_cfg_test |= cfg_args_positively_test(&args.stream());
595 }
596 }
597 i += 2;
598 continue;
599 }
600 }
601 i += 1;
602 }
603 TokenTree::Ident(id) if *id == "pub" => {
605 i += 1;
606 if let Some(TokenTree::Group(g)) = trees.get(i) {
607 if g.delimiter() == Delimiter::Parenthesis {
608 i += 1;
609 }
610 }
611 }
612 TokenTree::Ident(id) if *id == "mod" => {
613 if let (Some(TokenTree::Ident(name)), Some(TokenTree::Punct(semi))) =
614 (trees.get(i + 1), trees.get(i + 2))
615 {
616 if semi.as_char() == ';' {
617 out.push((name.to_string(), pending_cfg_test));
618 }
619 }
620 pending_cfg_test = false;
621 i += 1;
622 }
623 _ => {
624 pending_cfg_test = false;
625 i += 1;
626 }
627 }
628 }
629 out
630}
631
632pub fn mirror_module_tree(
635 src_dir: &Path,
636 dest_dir: &Path,
637 already_done: &mut std::collections::HashSet<PathBuf>,
638) -> Result<()> {
639 mirror_module_tree_with_extras(src_dir, dest_dir, already_done, &[])
640}
641
642pub fn mirror_module_tree_with_extras(
647 src_dir: &Path,
648 dest_dir: &Path,
649 already_done: &mut std::collections::HashSet<PathBuf>,
650 extras: &[(String, Vec<String>)],
651) -> Result<()> {
652 let test_only = if crate_is_force_strict() {
656 collect_test_only_files(src_dir)
657 } else {
658 std::collections::HashSet::new()
659 };
660 mirror_inner(src_dir, dest_dir, already_done, extras, &test_only)
661}
662
663fn mirror_inner(
664 src_dir: &Path,
665 dest_dir: &Path,
666 already_done: &mut std::collections::HashSet<PathBuf>,
667 extras: &[(String, Vec<String>)],
668 test_only: &std::collections::HashSet<PathBuf>,
669) -> Result<()> {
670 if !src_dir.is_dir() {
671 return Ok(());
672 }
673 fs::create_dir_all(dest_dir).with_context(|| format!("creating {}", dest_dir.display()))?;
674
675 for entry in
676 fs::read_dir(src_dir).with_context(|| format!("reading dir {}", src_dir.display()))?
677 {
678 let entry = entry?;
679 let path = entry.path();
680 let dest = dest_dir.join(entry.file_name());
681
682 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
683 let is_test_only = test_only.contains(&canonical);
684 if !already_done.insert(canonical) {
685 continue;
686 }
687
688 if path.is_dir() {
689 mirror_inner(&path, &dest, already_done, extras, test_only)?;
690 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
691 let source =
692 fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
693 let lower_this = trust_lower::is_strict_source(&source)
696 || (crate_is_force_strict() && !is_test_only);
697 if lower_this {
698 let out = trust_lower::lower_with_extra_callees_forced(
699 &source,
700 extras,
701 crate_is_force_strict(),
702 )
703 .with_context(|| format!("lowering {}", path.display()))?;
704 emit_diagnostics(&out, &source, &path)?;
705 let rewritten = lower_doctests_in_source(&out.source);
712 let tmp = dest_dir.join(format!(
713 ".{}.{}.tmp",
714 entry.file_name().to_string_lossy(),
715 std::process::id()
716 ));
717 fs::write(&tmp, &rewritten)?;
718 fs::rename(&tmp, &dest)?;
719 } else {
720 fs::copy(&path, &dest).with_context(|| format!("copying {}", path.display()))?;
724 }
725 } else {
726 let _ = fs::copy(&path, &dest);
729 }
730 }
731 Ok(())
732}
733
734pub fn lower_doctests_in_source(source: &str) -> String {
757 let mut out = String::with_capacity(source.len());
758 let lines: Vec<&str> = source.lines().collect();
759 let mut i = 0;
760 while i < lines.len() {
761 let (Some(prefix), Some(_)) = (doc_prefix(lines[i]), doc_body(lines[i])) else {
762 out.push_str(lines[i]);
763 out.push('\n');
764 i += 1;
765 continue;
766 };
767 let block_start = i;
769 while i < lines.len() && doc_prefix(lines[i]) == Some(prefix) {
770 i += 1;
771 }
772 let block_end = i;
773 let block = rewrite_doc_block(&lines[block_start..block_end], prefix);
774 out.push_str(&block);
775 }
777 out
778}
779
780fn doc_prefix(line: &str) -> Option<&'static str> {
781 let trimmed = line.trim_start();
782 if trimmed.starts_with("///") {
783 Some("///")
784 } else if trimmed.starts_with("//!") {
785 Some("//!")
786 } else {
787 None
788 }
789}
790
791fn doc_body(line: &str) -> Option<&str> {
792 let trimmed = line.trim_start();
793 let body = trimmed
794 .strip_prefix("///")
795 .or_else(|| trimmed.strip_prefix("//!"))?;
796 Some(body.strip_prefix(' ').unwrap_or(body))
797}
798
799fn rewrite_doc_block(lines: &[&str], prefix: &str) -> String {
802 let first = lines[0];
804 let indent_len = first.len() - first.trim_start().len();
805 let indent = &first[..indent_len];
806
807 let mut out = String::new();
811 let mut in_block = false;
812 let mut is_test_block = false;
813 let mut code_buf = String::new();
814 let mut block_indent_after_prefix = String::new();
815
816 for line in lines {
817 let body = doc_body(line).unwrap_or("");
818 let body_trim = body.trim_start();
819
820 if body_trim.starts_with("```") {
821 if !in_block {
822 let info = body_trim.trim_start_matches('`').trim();
824 is_test_block = info.is_empty()
825 || info == "rust"
826 || info.starts_with("rust,")
827 || info.starts_with("rust ");
828 in_block = true;
829 code_buf.clear();
830 block_indent_after_prefix.clear();
831 if let Some(stripped) = line.trim_start().strip_prefix(prefix) {
834 let after = stripped;
835 let extra_indent_len = after.len() - after.trim_start().len();
836 block_indent_after_prefix = after[..extra_indent_len].to_string();
837 }
838 out.push_str(line);
839 out.push('\n');
840 continue;
841 }
842 let lowered = if is_test_block {
844 try_lower_doctest(&code_buf).unwrap_or_else(|| code_buf.clone())
845 } else {
846 code_buf.clone()
847 };
848 for code_line in lowered.lines() {
849 out.push_str(indent);
850 out.push_str(prefix);
851 if !code_line.is_empty() {
852 if block_indent_after_prefix.is_empty() {
853 out.push(' ');
854 } else {
855 out.push_str(&block_indent_after_prefix);
856 }
857 }
858 out.push_str(code_line);
859 out.push('\n');
860 }
861 out.push_str(line);
862 out.push('\n');
863 in_block = false;
864 code_buf.clear();
865 continue;
866 }
867
868 if in_block {
869 code_buf.push_str(body);
871 code_buf.push('\n');
872 } else {
873 out.push_str(line);
874 out.push('\n');
875 }
876 }
877
878 if in_block {
880 for code_line in code_buf.lines() {
881 out.push_str(indent);
882 out.push_str(prefix);
883 out.push(' ');
884 out.push_str(code_line);
885 out.push('\n');
886 }
887 }
888 out
889}
890
891fn try_lower_doctest(snippet: &str) -> Option<String> {
895 if let Ok(out) = trust_lower::lower(snippet) {
897 if !out.diagnostics.iter().any(|d| d.is_error()) {
898 return Some(strip_hidden_doctest_prefix(out.source));
899 }
900 }
901 let wrapped = format!("fn __trust_doctest() {{\n{snippet}\n}}\n");
903 let out = trust_lower::lower(&wrapped).ok()?;
904 if out.diagnostics.iter().any(|d| d.is_error()) {
905 return None;
906 }
907 let unwrapped = unwrap_doctest_fn(&out.source)?;
912 Some(unwrapped)
913}
914
915fn unwrap_doctest_fn(source: &str) -> Option<String> {
916 let start = source.find("fn __trust_doctest()")?;
917 let open = source[start..].find('{')? + start;
918 let bytes = source.as_bytes();
920 let mut depth = 0i32;
921 let mut close = None;
922 for (i, &b) in bytes.iter().enumerate().skip(open) {
923 match b {
924 b'{' => depth += 1,
925 b'}' => {
926 depth -= 1;
927 if depth == 0 {
928 close = Some(i);
929 break;
930 }
931 }
932 _ => {}
933 }
934 }
935 let close = close?;
936 let body = &source[open + 1..close];
937 let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
940 while lines.first().is_some_and(|l| l.trim().is_empty()) {
941 lines.remove(0);
942 }
943 while lines.last().is_some_and(|l| l.trim().is_empty()) {
944 lines.pop();
945 }
946 let dedent = lines
947 .iter()
948 .filter(|l| !l.trim().is_empty())
949 .map(|l| l.len() - l.trim_start().len())
950 .min()
951 .unwrap_or(0);
952 let out: String = lines
953 .iter()
954 .map(|l| {
955 if l.len() >= dedent {
956 format!("{}\n", &l[dedent..])
957 } else {
958 "\n".to_string()
959 }
960 })
961 .collect();
962 Some(out)
963}
964
965fn strip_hidden_doctest_prefix(s: String) -> String {
971 s
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977
978 static MESSAGE_FORMAT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
981
982 struct MessageFormatGuard<'a> {
986 prev: Option<String>,
987 _lock: std::sync::MutexGuard<'a, ()>,
988 }
989
990 impl MessageFormatGuard<'_> {
991 fn set(value: Option<&str>) -> Self {
992 let lock = MESSAGE_FORMAT_LOCK
993 .lock()
994 .unwrap_or_else(|poisoned| poisoned.into_inner());
995 let prev = env::var("TRUST_MESSAGE_FORMAT").ok();
996 match value {
997 Some(v) => env::set_var("TRUST_MESSAGE_FORMAT", v),
998 None => env::remove_var("TRUST_MESSAGE_FORMAT"),
999 }
1000 MessageFormatGuard { prev, _lock: lock }
1001 }
1002 }
1003
1004 impl Drop for MessageFormatGuard<'_> {
1005 fn drop(&mut self) {
1006 match &self.prev {
1007 Some(prev) => env::set_var("TRUST_MESSAGE_FORMAT", prev),
1008 None => env::remove_var("TRUST_MESSAGE_FORMAT"),
1009 }
1010 }
1011 }
1012
1013 #[test]
1018 fn json_message_format_emits_parseable_document() {
1019 let _guard = MessageFormatGuard::set(Some("json"));
1020
1021 let source =
1022 "#![strict]\nfn main() { let v: Option<i32> = Some(1); let _ = v.unwrap(); }\n";
1023 let out = trust_lower::lower(source).expect("lowering strict source");
1024 let mut buf: Vec<u8> = Vec::new();
1025 let result = emit_diagnostics_to(&out, source, Path::new("src/main.rs"), &mut buf);
1026 assert!(result.is_err(), "R0001 is an error — must still bail");
1027
1028 let text = String::from_utf8(buf).expect("utf8 output");
1029 let doc: serde_json::Value =
1030 serde_json::from_str(text.trim()).expect("output must be valid JSON");
1031 assert_eq!(doc["file"], "src/main.rs");
1032 let rules: Vec<&str> = doc["diagnostics"]
1033 .as_array()
1034 .expect("diagnostics array")
1035 .iter()
1036 .filter_map(|d| d["rule"].as_str())
1037 .collect();
1038 assert!(rules.contains(&"R0001"), "expected R0001 in {rules:?}");
1039 }
1040
1041 #[test]
1043 fn default_message_format_is_human_lines() {
1044 let _guard = MessageFormatGuard::set(None);
1045 let source =
1046 "#![strict]\nfn main() { let v: Option<i32> = Some(1); let _ = v.unwrap(); }\n";
1047 let out = trust_lower::lower(source).expect("lowering strict source");
1048 let mut buf: Vec<u8> = Vec::new();
1049 let result = emit_diagnostics_to(&out, source, Path::new("src/main.rs"), &mut buf);
1050 assert!(result.is_err());
1051 let text = String::from_utf8(buf).expect("utf8 output");
1052 assert!(
1053 text.contains("[R0001] error:"),
1054 "expected human line, got: {text}"
1055 );
1056 }
1057
1058 #[test]
1062 fn cfg_test_mod_files_are_detected_transitively() {
1063 let base = std::env::temp_dir().join(format!("trust-rt88-{}", std::process::id()));
1064 let src = base.join("src");
1065 let _ = fs::remove_dir_all(&base);
1066 fs::create_dir_all(&src).unwrap();
1067 fs::write(
1068 src.join("main.rs"),
1069 "mod shipping;\n#[cfg(test)]\nmod tests;\nfn main() {}\n",
1070 )
1071 .unwrap();
1072 fs::write(src.join("shipping.rs"), "pub fn ship() {}\n").unwrap();
1073 fs::write(src.join("tests.rs"), "mod helpers;\nfn t() {}\n").unwrap();
1074 fs::write(src.join("helpers.rs"), "pub fn helper() {}\n").unwrap();
1075
1076 let test_only = collect_test_only_files(&src);
1077 let has = |name: &str| {
1078 test_only
1079 .iter()
1080 .any(|p| p.file_name().and_then(|f| f.to_str()) == Some(name))
1081 };
1082 assert!(has("tests.rs"), "directly cfg(test)-declared file");
1083 assert!(has("helpers.rs"), "transitively reached through tests.rs");
1084 assert!(!has("shipping.rs"), "normal mod stays enforced");
1085 assert!(!has("main.rs"), "the crate root is never test-only");
1086
1087 let _ = fs::remove_dir_all(&base);
1088 }
1089
1090 #[test]
1094 fn negated_test_cfgs_are_not_test_only() {
1095 let base = std::env::temp_dir().join(format!("trust-pr1-{}", std::process::id()));
1096 let src = base.join("src");
1097 let _ = fs::remove_dir_all(&base);
1098 fs::create_dir_all(&src).unwrap();
1099 fs::write(
1100 src.join("main.rs"),
1101 "#[cfg(not(test))]\nmod prod;\n\
1102 #[cfg(all(unix, not(test)))]\nmod prod_unix;\n\
1103 #[cfg(all(unix, test))]\nmod unix_tests;\n\
1104 #[cfg(test)]\nmod tests;\n\
1105 #[cfg(feature = \"test\")]\nmod feature_named_test;\n\
1106 fn main() {}\n",
1107 )
1108 .unwrap();
1109 for name in [
1110 "prod.rs",
1111 "prod_unix.rs",
1112 "unix_tests.rs",
1113 "tests.rs",
1114 "feature_named_test.rs",
1115 ] {
1116 fs::write(src.join(name), "pub fn x() {}\n").unwrap();
1117 }
1118
1119 let test_only = collect_test_only_files(&src);
1120 let has = |name: &str| {
1121 test_only
1122 .iter()
1123 .any(|p| p.file_name().and_then(|f| f.to_str()) == Some(name))
1124 };
1125 assert!(!has("prod.rs"), "cfg(not(test)) is a production module");
1126 assert!(!has("prod_unix.rs"), "all(unix, not(test)) is production");
1127 assert!(has("unix_tests.rs"), "all(unix, test) is test-only");
1128 assert!(has("tests.rs"), "plain cfg(test) is test-only");
1129 assert!(
1130 !has("feature_named_test.rs"),
1131 "feature = \"test\" is a feature gate, not the test predicate"
1132 );
1133
1134 let _ = fs::remove_dir_all(&base);
1135 }
1136
1137 #[test]
1140 fn force_strict_is_scoped_by_package_name() {
1141 assert!(force_strict_for(Some("my-app"), Some("my-app")));
1143 assert!(!force_strict_for(Some("my-app"), Some("serde")));
1146 assert!(force_strict_for(Some("a, b ,c"), Some("b")));
1148 assert!(!force_strict_for(None, Some("my-app")));
1150 assert!(!force_strict_for(Some("my-app"), None));
1151 assert!(!force_strict_for(Some("a,"), Some("")));
1153 }
1154
1155 #[test]
1160 fn mirror_copies_rather_than_hardlinks_source() {
1161 let base = std::env::temp_dir().join(format!("trust-rt75-{}", std::process::id()));
1162 let src = base.join("src");
1163 let dest = base.join("cache");
1164 let _ = fs::remove_dir_all(&base);
1165 fs::create_dir_all(&src).expect("create src");
1166 let src_file = src.join("plain.rs");
1167 fs::write(&src_file, "pub fn keep() {}\n").expect("write src");
1168
1169 let mut visited = std::collections::HashSet::new();
1170 mirror_module_tree(&src, &dest, &mut visited).expect("mirror");
1171
1172 fs::write(dest.join("plain.rs"), "").expect("clobber cache");
1174
1175 let after = fs::read_to_string(&src_file).expect("read src after");
1177 assert_eq!(
1178 after, "pub fn keep() {}\n",
1179 "source file was corrupted — cache shares an inode with it"
1180 );
1181 let _ = fs::remove_dir_all(&base);
1182 }
1183}