Skip to main content

nyx_scanner/utils/
project.rs

1#![allow(clippy::collapsible_if)]
2
3use crate::errors::{NyxError, NyxResult};
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8/// Determine `<project-name, path/to/<project>.sqlite>`.
9pub fn get_project_info(project_path: &Path, config_dir: &Path) -> NyxResult<(String, PathBuf)> {
10    let project_name = project_path
11        .file_name()
12        .and_then(|n| n.to_str())
13        .ok_or_else(|| NyxError::Other("Unable to determine project name".into()))?;
14
15    let db_name = sanitize_project_name(project_name);
16    let db_path = config_dir.join(format!("{db_name}.sqlite"));
17
18    Ok((project_name.to_owned(), db_path))
19}
20
21pub fn sanitize_project_name(name: &str) -> String {
22    name.to_lowercase()
23        .chars()
24        .map(|c| match c {
25            ' ' | '\t' | '\n' | '\r' => '_',
26            c if c.is_alphanumeric() || c == '_' || c == '-' => c,
27            _ => '_',
28        })
29        .collect::<String>()
30        .split('_')
31        .filter(|s| !s.is_empty())
32        .collect::<Vec<_>>()
33        .join("_")
34}
35
36/// A web framework detected from project manifests.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum DetectedFramework {
39    Express,
40    Koa,
41    Fastify,
42    React,
43    Flask,
44    Django,
45    Spring,
46    Gin,
47    Echo,
48    Laravel,
49    Rails,
50    Sinatra,
51    ActixWeb,
52    Rocket,
53    Axum,
54}
55
56/// Frameworks detected in the project root.
57#[derive(Debug, Clone, Default)]
58pub struct FrameworkContext {
59    pub frameworks: Vec<DetectedFramework>,
60    /// Language ecosystems whose root manifest existed and was inspected.
61    /// Lets `lang_has_web_framework` distinguish "no manifest at all" from
62    /// "manifest present but listed no matching framework" — the second
63    /// case is a positive signal that the project has no HTTP boundary in
64    /// that language, the first is just absence-of-information.
65    pub inspected_langs: std::collections::HashSet<&'static str>,
66}
67
68impl FrameworkContext {
69    pub fn has(&self, fw: DetectedFramework) -> bool {
70        self.frameworks.contains(&fw)
71    }
72
73    /// Three-valued web-framework presence query for a language slug.
74    ///
75    /// * `Some(true)` ─ at least one framework for `lang` is in `frameworks`.
76    /// * `Some(false)` ─ a manifest for `lang` was inspected but listed no
77    ///   matching framework.  The project genuinely has no HTTP boundary
78    ///   in this language.
79    /// * `None` ─ no manifest for `lang` was inspected (e.g. single-file
80    ///   scans without a project root).  Caller should fall back to
81    ///   prior-behavior heuristics.
82    pub fn lang_has_web_framework(&self, lang: &str) -> Option<bool> {
83        let (frameworks_for_lang, manifest_lang_key): (&[DetectedFramework], &str) = match lang {
84            "javascript" | "typescript" | "js" | "ts" => (
85                &[
86                    DetectedFramework::Express,
87                    DetectedFramework::Koa,
88                    DetectedFramework::Fastify,
89                ],
90                "node",
91            ),
92            "python" | "py" => (
93                &[DetectedFramework::Flask, DetectedFramework::Django],
94                "python",
95            ),
96            "java" => (&[DetectedFramework::Spring], "java"),
97            "go" => (&[DetectedFramework::Gin, DetectedFramework::Echo], "go"),
98            "ruby" | "rb" => (
99                &[DetectedFramework::Rails, DetectedFramework::Sinatra],
100                "ruby",
101            ),
102            "php" => (&[DetectedFramework::Laravel], "php"),
103            "rust" | "rs" => (
104                &[
105                    DetectedFramework::Axum,
106                    DetectedFramework::ActixWeb,
107                    DetectedFramework::Rocket,
108                ],
109                "rust",
110            ),
111            _ => return None,
112        };
113        if frameworks_for_lang.iter().any(|fw| self.has(*fw)) {
114            return Some(true);
115        }
116        if self.inspected_langs.contains(manifest_lang_key) {
117            return Some(false);
118        }
119        None
120    }
121}
122
123/// Maximum bytes to read from each manifest file.
124const MANIFEST_READ_LIMIT: usize = 64 * 1024;
125
126/// Read up to `MANIFEST_READ_LIMIT` bytes from a file.
127fn read_bounded(path: &Path) -> Option<String> {
128    let file = fs::File::open(path).ok()?;
129    let mut reader = std::io::BufReader::new(file).take(MANIFEST_READ_LIMIT as u64);
130    let mut out = String::new();
131    reader.read_to_string(&mut out).ok()?;
132    Some(out)
133}
134
135/// Scan file source bytes for import statements referencing known web
136/// frameworks. Used to augment the project-level [`FrameworkContext`] with
137/// per-file signals, so that single-file scans (no package.json / go.mod /
138/// Gemfile nearby) still trigger framework-conditional rules.
139///
140/// Intentionally a coarse byte-level substring check against the quoted module
141/// specifier (e.g. `'fastify'`, `"github.com/labstack/echo/v4"`,
142/// `'sinatra'`). Only the first 8 KiB of the file are inspected, imports /
143/// requires live at the top. Returns an empty list for languages without a
144/// framework detection policy here.
145pub fn detect_in_file_frameworks(bytes: &[u8], lang_slug: &str) -> Vec<DetectedFramework> {
146    let head_len = bytes.len().min(8 * 1024);
147    let head = match std::str::from_utf8(&bytes[..head_len]) {
148        Ok(s) => s,
149        Err(_) => return Vec::new(),
150    };
151    let matches_module = |name: &str| {
152        // Quoted single or double, as appears in `from 'fastify'` /
153        // `require("fastify")` / `import('fastify')` / `require 'sinatra'`.
154        head.contains(&format!("'{name}'")) || head.contains(&format!("\"{name}\""))
155    };
156    let mut fws = Vec::new();
157    match lang_slug {
158        "javascript" | "typescript" | "js" | "ts" => {
159            if matches_module("fastify") {
160                fws.push(DetectedFramework::Fastify);
161            }
162            if matches_module("express") {
163                fws.push(DetectedFramework::Express);
164            }
165            if matches_module("koa")
166                || matches_module("@koa/router")
167                || matches_module("koa-router")
168            {
169                fws.push(DetectedFramework::Koa);
170            }
171        }
172        "go" => {
173            // Go imports are quoted module paths. Match a distinctive prefix
174            // so any major version (`/v3`, `/v4`, …) still detects.
175            if head.contains("\"github.com/labstack/echo") {
176                fws.push(DetectedFramework::Echo);
177            }
178            if head.contains("\"github.com/gin-gonic/gin\"") {
179                fws.push(DetectedFramework::Gin);
180            }
181        }
182        "ruby" | "rb" => {
183            // Ruby requires: `require 'sinatra'` or `require 'sinatra/base'`.
184            if matches_module("sinatra") || matches_module("sinatra/base") {
185                fws.push(DetectedFramework::Sinatra);
186            }
187            // Rails apps don't always `require 'rails'` directly (they load
188            // via config/boot.rb), but when they do, surface it.
189            if matches_module("rails") || matches_module("rails/all") {
190                fws.push(DetectedFramework::Rails);
191            }
192        }
193        // Rust is intentionally not handled here — adding axum / actix_web
194        // / rocket detection here would also flip framework-conditional
195        // *label* rules on for files in workspaces whose root Cargo.toml
196        // doesn't list the crate (e.g. meilisearch's root, which carries
197        // actix-web only in subcrates), and the existing actix label set
198        // marks `HttpResponse.json` as a `Cap::HTML_ESCAPE` sink ─ a
199        // pattern that fires on every actix route that echoes a path
200        // parameter back to the client (legitimate behavior, not XSS).
201        //
202        // The auth-analysis path uses `auth_analysis::extract`'s own
203        // per-file Rust check (see `compute_web_framework_signal`) so the
204        // signal is available without touching the label augmentation.
205        _ => {}
206    }
207    fws
208}
209
210/// Coarse per-file signal: does the file's leading byte range mention
211/// at least one Rust web-framework symbol path (`axum::`, `actix_web::`,
212/// `rocket::`)?  Used by [`crate::auth_analysis::extract`] to gate the
213/// `is_external_input_param_name` arm of `unit_has_user_input_evidence`
214/// without affecting framework-conditional *label* rules.
215///
216/// Returns `false` for non-Rust source.
217pub fn rust_file_imports_web_framework(bytes: &[u8]) -> bool {
218    let head_len = bytes.len().min(8 * 1024);
219    let head = match std::str::from_utf8(&bytes[..head_len]) {
220        Ok(s) => s,
221        Err(_) => return false,
222    };
223    head.contains("axum::")
224        || head.contains("axum_extra::")
225        || head.contains("actix_web::")
226        || head.contains("rocket::")
227}
228
229/// Detect frameworks from manifest files in the project root.
230pub fn detect_frameworks(root: &Path) -> FrameworkContext {
231    let mut fws = Vec::new();
232    let mut inspected: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
233
234    // ── Node.js (package.json) ──
235    if let Some(content) = read_bounded(&root.join("package.json")) {
236        inspected.insert("node");
237        // Crude substring search in the "dependencies" block area.
238        // Good enough for detection, no JSON parsing overhead.
239        if content.contains("\"express\"") {
240            fws.push(DetectedFramework::Express);
241        }
242        if (content.contains("\"koa\"")
243            || content.contains("\"@koa/router\"")
244            || content.contains("\"koa-router\""))
245            && !fws.contains(&DetectedFramework::Koa)
246        {
247            fws.push(DetectedFramework::Koa);
248        }
249        if content.contains("\"fastify\"") && !fws.contains(&DetectedFramework::Fastify) {
250            fws.push(DetectedFramework::Fastify);
251        }
252        if content.contains("\"react\"") {
253            fws.push(DetectedFramework::React);
254        }
255    }
256
257    // ── Python ──
258    for name in &["requirements.txt", "Pipfile", "pyproject.toml"] {
259        if let Some(content) = read_bounded(&root.join(name)) {
260            inspected.insert("python");
261            let lower = content.to_ascii_lowercase();
262            if lower.contains("flask") && !fws.contains(&DetectedFramework::Flask) {
263                fws.push(DetectedFramework::Flask);
264            }
265            if lower.contains("django") && !fws.contains(&DetectedFramework::Django) {
266                fws.push(DetectedFramework::Django);
267            }
268        }
269    }
270
271    // ── Java (Maven / Gradle) ──
272    for name in &["pom.xml", "build.gradle", "build.gradle.kts"] {
273        if let Some(content) = read_bounded(&root.join(name)) {
274            inspected.insert("java");
275            if (content.contains("spring-boot") || content.contains("spring-web"))
276                && !fws.contains(&DetectedFramework::Spring)
277            {
278                fws.push(DetectedFramework::Spring);
279            }
280        }
281    }
282
283    // ── Go (go.mod) ──
284    if let Some(content) = read_bounded(&root.join("go.mod")) {
285        inspected.insert("go");
286        if content.contains("gin-gonic/gin") {
287            fws.push(DetectedFramework::Gin);
288        }
289        if content.contains("labstack/echo") {
290            fws.push(DetectedFramework::Echo);
291        }
292    }
293
294    // ── PHP (composer.json) ──
295    if let Some(content) = read_bounded(&root.join("composer.json")) {
296        inspected.insert("php");
297        if content.contains("laravel/framework") {
298            fws.push(DetectedFramework::Laravel);
299        }
300    }
301
302    // ── Ruby (Gemfile) ──
303    if let Some(content) = read_bounded(&root.join("Gemfile")) {
304        inspected.insert("ruby");
305        if content.contains("'rails'") || content.contains("\"rails\"") {
306            fws.push(DetectedFramework::Rails);
307        }
308        if content.contains("'sinatra'") || content.contains("\"sinatra\"") {
309            fws.push(DetectedFramework::Sinatra);
310        }
311    }
312
313    // ── Rust (Cargo.toml) ──
314    if let Some(content) = read_bounded(&root.join("Cargo.toml")) {
315        inspected.insert("rust");
316        if content.contains("actix-web") {
317            fws.push(DetectedFramework::ActixWeb);
318        }
319        if content.contains("rocket") && !fws.contains(&DetectedFramework::Rocket) {
320            fws.push(DetectedFramework::Rocket);
321        }
322        if content.contains("axum") {
323            fws.push(DetectedFramework::Axum);
324        }
325    }
326
327    FrameworkContext {
328        frameworks: fws,
329        inspected_langs: inspected,
330    }
331}
332
333#[test]
334fn sanitize_project_name_is_idempotent_and_lossless_enough() {
335    let samples = [
336        ("My Project", "my_project"),
337        ("Hello-World", "hello-world"),
338        ("mixed_case", "mixed_case"),
339        ("tabs\tspaces\n", "tabs_spaces"),
340        ("   multiple   ", "multiple"),
341        ("weird@$*chars", "weird_chars"),
342    ];
343
344    for (input, expected) in samples {
345        assert_eq!(sanitize_project_name(input), expected, "input: {input}");
346        assert_eq!(sanitize_project_name(expected), expected);
347    }
348}
349
350#[test]
351fn get_project_info_uses_sanitized_name_in_sqlite_path() {
352    let tmp = tempfile::tempdir().unwrap();
353    let root = tmp.path();
354
355    let project_dir = root.join("Example Project");
356    std::fs::create_dir(&project_dir).unwrap();
357
358    let (project_name, db_path) =
359        get_project_info(&project_dir, root).expect("should detect project");
360
361    assert_eq!(project_name, "Example Project");
362    assert_eq!(db_path, root.join("example_project.sqlite"));
363}
364
365#[test]
366fn detect_frameworks_from_package_json() {
367    let tmp = tempfile::tempdir().unwrap();
368    let root = tmp.path();
369    fs::write(
370        root.join("package.json"),
371        r#"{"dependencies": {"express": "^4.18.0", "koa": "^2.15.0", "fastify": "^4.0.0", "react": "^18.0.0"}}"#,
372    )
373    .unwrap();
374    let ctx = detect_frameworks(root);
375    assert!(ctx.has(DetectedFramework::Express));
376    assert!(ctx.has(DetectedFramework::Koa));
377    assert!(ctx.has(DetectedFramework::Fastify));
378    assert!(ctx.has(DetectedFramework::React));
379    assert!(!ctx.has(DetectedFramework::Flask));
380}
381
382#[test]
383fn detect_frameworks_empty_dir() {
384    let tmp = tempfile::tempdir().unwrap();
385    let ctx = detect_frameworks(tmp.path());
386    assert!(ctx.frameworks.is_empty());
387}
388
389#[test]
390fn detect_frameworks_gemfile_rails() {
391    let tmp = tempfile::tempdir().unwrap();
392    let root = tmp.path();
393    fs::write(root.join("Gemfile"), "gem 'rails', '~> 7.0'\ngem 'puma'\n").unwrap();
394    let ctx = detect_frameworks(root);
395    assert!(ctx.has(DetectedFramework::Rails));
396    assert!(!ctx.has(DetectedFramework::Sinatra));
397}
398
399#[test]
400fn detect_frameworks_gemfile_sinatra() {
401    let tmp = tempfile::tempdir().unwrap();
402    let root = tmp.path();
403    fs::write(root.join("Gemfile"), "gem 'sinatra'\ngem 'puma'\n").unwrap();
404    let ctx = detect_frameworks(root);
405    assert!(ctx.has(DetectedFramework::Sinatra));
406    assert!(!ctx.has(DetectedFramework::Rails));
407}
408
409#[test]
410fn detect_frameworks_python_flask_from_requirements() {
411    let tmp = tempfile::tempdir().unwrap();
412    let root = tmp.path();
413    fs::write(
414        root.join("requirements.txt"),
415        "Flask==2.3.0\nrequests>=2.28\n",
416    )
417    .unwrap();
418    let ctx = detect_frameworks(root);
419    assert!(ctx.has(DetectedFramework::Flask));
420    assert!(!ctx.has(DetectedFramework::Django));
421}
422
423#[test]
424fn detect_frameworks_python_django_from_pyproject() {
425    let tmp = tempfile::tempdir().unwrap();
426    let root = tmp.path();
427    fs::write(
428        root.join("pyproject.toml"),
429        "[project]\nname = \"myapp\"\ndependencies = [\"django>=4.0\"]\n",
430    )
431    .unwrap();
432    let ctx = detect_frameworks(root);
433    assert!(ctx.has(DetectedFramework::Django));
434    assert!(!ctx.has(DetectedFramework::Flask));
435}
436
437#[test]
438fn detect_frameworks_go_mod_gin() {
439    let tmp = tempfile::tempdir().unwrap();
440    let root = tmp.path();
441    fs::write(
442        root.join("go.mod"),
443        "module example.com/app\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.9.0\n)\n",
444    )
445    .unwrap();
446    let ctx = detect_frameworks(root);
447    assert!(ctx.has(DetectedFramework::Gin));
448    assert!(!ctx.has(DetectedFramework::Echo));
449}
450
451#[test]
452fn detect_frameworks_go_mod_echo() {
453    let tmp = tempfile::tempdir().unwrap();
454    let root = tmp.path();
455    fs::write(
456        root.join("go.mod"),
457        "module example.com/app\n\nrequire (\n\tgithub.com/labstack/echo/v4 v4.11.0\n)\n",
458    )
459    .unwrap();
460    let ctx = detect_frameworks(root);
461    assert!(ctx.has(DetectedFramework::Echo));
462    assert!(!ctx.has(DetectedFramework::Gin));
463}
464
465#[test]
466fn detect_frameworks_java_spring_from_pom_xml() {
467    let tmp = tempfile::tempdir().unwrap();
468    let root = tmp.path();
469    fs::write(
470        root.join("pom.xml"),
471        "<project>\n  <dependencies>\n    <dependency>\n      <groupId>org.springframework.boot</groupId>\n      <artifactId>spring-boot-starter-web</artifactId>\n    </dependency>\n  </dependencies>\n</project>\n",
472    )
473    .unwrap();
474    let ctx = detect_frameworks(root);
475    assert!(ctx.has(DetectedFramework::Spring));
476}
477
478#[test]
479fn detect_frameworks_java_spring_from_build_gradle() {
480    let tmp = tempfile::tempdir().unwrap();
481    let root = tmp.path();
482    fs::write(
483        root.join("build.gradle"),
484        "plugins {\n    id 'org.springframework.boot' version '3.1.0'\n}\ndependencies {\n    implementation 'org.springframework.boot:spring-web:3.1.0'\n}\n",
485    )
486    .unwrap();
487    let ctx = detect_frameworks(root);
488    assert!(ctx.has(DetectedFramework::Spring));
489}
490
491#[test]
492fn detect_frameworks_php_laravel_from_composer_json() {
493    let tmp = tempfile::tempdir().unwrap();
494    let root = tmp.path();
495    fs::write(
496        root.join("composer.json"),
497        r#"{"require": {"laravel/framework": "^10.0", "php": "^8.1"}}"#,
498    )
499    .unwrap();
500    let ctx = detect_frameworks(root);
501    assert!(ctx.has(DetectedFramework::Laravel));
502}
503
504#[test]
505fn detect_frameworks_rust_axum_from_cargo_toml() {
506    let tmp = tempfile::tempdir().unwrap();
507    let root = tmp.path();
508    fs::write(
509        root.join("Cargo.toml"),
510        "[dependencies]\naxum = \"0.7\"\ntokio = { version = \"1\", features = [\"full\"] }\n",
511    )
512    .unwrap();
513    let ctx = detect_frameworks(root);
514    assert!(ctx.has(DetectedFramework::Axum));
515    assert!(!ctx.has(DetectedFramework::ActixWeb));
516    assert!(!ctx.has(DetectedFramework::Rocket));
517}
518
519#[test]
520fn detect_frameworks_rust_actix_web_from_cargo_toml() {
521    let tmp = tempfile::tempdir().unwrap();
522    let root = tmp.path();
523    fs::write(
524        root.join("Cargo.toml"),
525        "[dependencies]\nactix-web = \"4\"\n",
526    )
527    .unwrap();
528    let ctx = detect_frameworks(root);
529    assert!(ctx.has(DetectedFramework::ActixWeb));
530}
531
532#[test]
533fn detect_frameworks_multiple_in_same_project() {
534    let tmp = tempfile::tempdir().unwrap();
535    let root = tmp.path();
536    // A project using both Express and React
537    fs::write(
538        root.join("package.json"),
539        r#"{"dependencies": {"express": "^4", "@koa/router": "^12", "fastify": "^4", "react": "^18"}}"#,
540    )
541    .unwrap();
542    let ctx = detect_frameworks(root);
543    assert!(ctx.has(DetectedFramework::Express));
544    assert!(ctx.has(DetectedFramework::Koa));
545    assert!(ctx.has(DetectedFramework::Fastify));
546    assert!(ctx.has(DetectedFramework::React));
547    assert_eq!(ctx.frameworks.len(), 4);
548}
549
550#[test]
551fn sanitize_project_name_numeric_and_special() {
552    assert_eq!(sanitize_project_name("project123"), "project123");
553    assert_eq!(sanitize_project_name("123"), "123");
554    assert_eq!(sanitize_project_name("a.b.c"), "a_b_c");
555    // hyphens are preserved as-is (only underscores are collapsed)
556    assert_eq!(sanitize_project_name("a--b"), "a--b");
557    // Leading/trailing underscores from replacements get collapsed
558    assert_eq!(sanitize_project_name("__init__"), "init");
559}
560
561#[test]
562fn get_project_info_returns_error_for_root_path() {
563    let tmp = tempfile::tempdir().unwrap();
564    // A path that ends with "/" (root) has no file_name
565    let result = get_project_info(std::path::Path::new("/"), tmp.path());
566    assert!(result.is_err());
567}
568
569#[test]
570fn framework_context_has_is_false_for_absent_framework() {
571    let ctx = FrameworkContext::default();
572    assert!(!ctx.has(DetectedFramework::Express));
573    assert!(!ctx.has(DetectedFramework::Flask));
574    assert!(!ctx.has(DetectedFramework::Spring));
575}
576
577#[test]
578fn lang_has_web_framework_three_valued_for_rust() {
579    let tmp = tempfile::tempdir().unwrap();
580    let root = tmp.path();
581    // Cargo.toml present, no axum / actix-web / rocket → Some(false).
582    fs::write(root.join("Cargo.toml"), "[dependencies]\nserde = \"1\"\n").unwrap();
583    let ctx = detect_frameworks(root);
584    assert_eq!(ctx.lang_has_web_framework("rust"), Some(false));
585    assert_eq!(ctx.lang_has_web_framework("python"), None);
586
587    // Cargo.toml present and names axum → Some(true).
588    fs::write(root.join("Cargo.toml"), "[dependencies]\naxum = \"0.7\"\n").unwrap();
589    let ctx = detect_frameworks(root);
590    assert_eq!(ctx.lang_has_web_framework("rust"), Some(true));
591}
592
593#[test]
594fn lang_has_web_framework_none_when_manifest_absent() {
595    // No Cargo.toml at root → Rust manifest not inspected → None.
596    let tmp = tempfile::tempdir().unwrap();
597    let ctx = detect_frameworks(tmp.path());
598    assert_eq!(ctx.lang_has_web_framework("rust"), None);
599    assert_eq!(ctx.lang_has_web_framework("python"), None);
600    assert_eq!(ctx.lang_has_web_framework("ruby"), None);
601}
602
603#[test]
604fn rust_file_imports_web_framework_recognises_axum_actix_rocket() {
605    assert!(rust_file_imports_web_framework(
606        b"use axum::Router;\nfn main() {}\n"
607    ));
608    assert!(rust_file_imports_web_framework(
609        b"use actix_web::web;\nfn main() {}\n"
610    ));
611    assert!(rust_file_imports_web_framework(
612        b"use rocket::get;\nfn main() {}\n"
613    ));
614    assert!(rust_file_imports_web_framework(
615        b"use axum_extra::routing::RouterExt;\n"
616    ));
617    // Not a web framework import → false.
618    assert!(!rust_file_imports_web_framework(
619        b"use std::path::Path;\nuse serde::Deserialize;\nfn main() {}\n"
620    ));
621    // Bare crate name in a comment doesn't satisfy the `<crate>::`
622    // path prefix — substring is conservative on purpose.
623    assert!(!rust_file_imports_web_framework(
624        b"// migrating away from axum\nfn main() {}\n"
625    ));
626}
627
628#[test]
629fn detect_in_file_frameworks_go_echo() {
630    let src = b"package main\nimport (\n\t\"net/http\"\n\t\"github.com/labstack/echo/v4\"\n)\nfunc x() {}\n";
631    let fws = detect_in_file_frameworks(src, "go");
632    assert!(fws.contains(&DetectedFramework::Echo));
633    assert!(!fws.contains(&DetectedFramework::Gin));
634}
635
636#[test]
637fn detect_in_file_frameworks_go_gin() {
638    let src = b"package main\nimport \"github.com/gin-gonic/gin\"\n";
639    let fws = detect_in_file_frameworks(src, "go");
640    assert!(fws.contains(&DetectedFramework::Gin));
641    assert!(!fws.contains(&DetectedFramework::Echo));
642}
643
644#[test]
645fn detect_in_file_frameworks_ruby_sinatra() {
646    let src = b"require 'sinatra'\nget '/' do\n  'hi'\nend\n";
647    let fws = detect_in_file_frameworks(src, "ruby");
648    assert!(fws.contains(&DetectedFramework::Sinatra));
649    assert!(!fws.contains(&DetectedFramework::Rails));
650}
651
652#[test]
653fn detect_in_file_frameworks_ruby_sinatra_base() {
654    let src = b"require \"sinatra/base\"\nclass App < Sinatra::Base; end\n";
655    let fws = detect_in_file_frameworks(src, "ruby");
656    assert!(fws.contains(&DetectedFramework::Sinatra));
657}
658
659#[test]
660fn detect_in_file_frameworks_plain_go_no_framework() {
661    let src = b"package main\nimport \"fmt\"\nfunc main() { fmt.Println(\"hi\") }\n";
662    let fws = detect_in_file_frameworks(src, "go");
663    assert!(fws.is_empty());
664}
665
666#[test]
667fn detect_in_file_frameworks_plain_ruby_no_framework() {
668    let src = b"require 'json'\nputs JSON.parse('{}')\n";
669    let fws = detect_in_file_frameworks(src, "ruby");
670    assert!(fws.is_empty());
671}