1#![allow(clippy::collapsible_if)]
2
3use crate::errors::{NyxError, NyxResult};
4use std::fs;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8pub 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#[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#[derive(Debug, Clone, Default)]
58pub struct FrameworkContext {
59 pub frameworks: Vec<DetectedFramework>,
60 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 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
123const MANIFEST_READ_LIMIT: usize = 64 * 1024;
125
126fn 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
135pub 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 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 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 if matches_module("sinatra") || matches_module("sinatra/base") {
185 fws.push(DetectedFramework::Sinatra);
186 }
187 if matches_module("rails") || matches_module("rails/all") {
190 fws.push(DetectedFramework::Rails);
191 }
192 }
193 _ => {}
206 }
207 fws
208}
209
210pub 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
229pub 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 if let Some(content) = read_bounded(&root.join("package.json")) {
236 inspected.insert("node");
237 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 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 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 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 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 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 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 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 assert_eq!(sanitize_project_name("a--b"), "a--b");
557 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 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 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 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 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 assert!(!rust_file_imports_web_framework(
619 b"use std::path::Path;\nuse serde::Deserialize;\nfn main() {}\n"
620 ));
621 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}