use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum Category {
Apps,
Services,
Ui,
Embedded,
Ml,
Tools,
Labs,
}
impl Category {
pub fn dir_name(&self) -> &'static str {
match self {
Self::Apps => "apps",
Self::Services => "services",
Self::Ui => "ui",
Self::Embedded => "embedded",
Self::Ml => "ml",
Self::Tools => "tools",
Self::Labs => "labs",
}
}
pub fn label(&self) -> String {
let s = format!("{:<8}", self.dir_name());
match self {
Self::Apps => s.blue().bold().to_string(),
Self::Services => s.cyan().bold().to_string(),
Self::Ui => s.magenta().bold().to_string(),
Self::Embedded => s.yellow().bold().to_string(),
Self::Ml => s.green().bold().to_string(),
Self::Tools => s.white().bold().to_string(),
Self::Labs => s.truecolor(255, 100, 50).bold().to_string(),
}
}
pub fn all() -> &'static [Self] {
&[
Self::Apps,
Self::Services,
Self::Ui,
Self::Embedded,
Self::Ml,
Self::Tools,
Self::Labs,
]
}
}
pub const KNOWN_SUFFIXES: &[&str] = &[
"api", "web", "mob", "mobile", "desk", "desktop",
"mono", "cli", "fw", "lib", "core", "ui", "website",
"backend", "frontend", "server", "client",
"app", "apps", "bot", "worker", "jobs",
"admin", "dashboard", "landing", "docs",
];
pub fn split_suffix(name: &str) -> Option<(&str, &str)> {
for sep in ['-', '_'] {
if let Some(pos) = name.rfind(sep) {
let suffix_raw = &name[pos + 1..];
let suffix_low = suffix_raw.to_lowercase();
if KNOWN_SUFFIXES.contains(&suffix_low.as_str()) {
return Some((&name[..pos], suffix_raw));
}
}
}
None
}
#[allow(dead_code)]
pub fn prefix_key(name: &str) -> Option<String> {
split_suffix(name).map(|(prefix, _)| prefix.to_lowercase())
}
pub fn classify(path: &Path) -> Category {
let has = |f: &str| path.join(f).exists();
if has("doc-lab.md") {
return Category::Labs;
}
let has_cargo = has("Cargo.toml");
let has_pkg = has("package.json");
let has_tauri = has("src-tauri");
let has_uv = has("uv.lock") || has(".python-version");
let has_py = has_uv || has("requirements.txt") || has("pyproject.toml") || has("setup.py");
let has_nb = has_ext(path, "ipynb");
let has_mem_x = has("memory.x"); let has_openocd = has("openocd.cfg") || has(".probe-rs");
let name_lower = path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase());
if let Some((_prefix, suffix)) = name_lower.as_deref().and_then(split_suffix) {
match suffix {
"fw" => return Category::Embedded,
"mob" | "mobile" => return Category::Ui,
"web" | "ui" => return Category::Ui,
"api" | "core" | "backend"
| "server" => {
if !has_tauri && !(has_cargo && has_pkg) {
return Category::Services;
}
}
"frontend" | "client"
| "landing" | "dashboard" => return Category::Ui,
"mono" | "desktop" | "desk" => return Category::Apps,
_ => {}
}
}
if has_mem_x || has_openocd || is_embedded_cargo(path) {
return Category::Embedded;
}
if has_tauri || (has_cargo && has_pkg) {
return Category::Apps;
}
if has_py || has_nb {
let ml_markers = [
"train.py", "model.py", "dataset.py",
"notebooks", "data", "models", "checkpoints",
];
if ml_markers.iter().any(|m| path.join(m).exists()) {
return Category::Ml;
}
return Category::Tools;
}
if has_pkg && !has_cargo {
match read_pkg_kind(path) {
PkgKind::Frontend => return Category::Ui,
PkgKind::Backend => return Category::Services,
PkgKind::Fullstack => return Category::Apps,
PkgKind::Unknown => {
if has("server") || has("backend") || has("api") {
return Category::Apps;
}
return Category::Ui;
}
}
}
if has_cargo && !has_pkg {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_lowercase();
if name.contains("cli") || name.contains("tool") || name.contains("util") {
return Category::Tools;
}
return Category::Services;
}
Category::Labs
}
#[derive(Debug)]
enum PkgKind { Frontend, Backend, Fullstack, Unknown }
const FRONTEND_DEPS: &[&str] = &[
"react", "react-dom", "vue", "svelte", "solid-js",
"next", "nuxt", "sveltekit", "@sveltejs/kit",
"vite", "webpack", "parcel", "rollup", "esbuild",
"astro", "remix", "@remix-run/react",
"gatsby", "angular", "@angular/core",
"tailwindcss", "@shadcn/ui", "radix-ui",
"react-router", "react-router-dom", "wouter",
];
const BACKEND_DEPS: &[&str] = &[
"hono", "express", "fastify", "koa", "restify",
"nestjs", "@nestjs/core", "@nestjs/common",
"elysia", "h3", "nitro",
"better-sqlite3", "pg", "mysql2", "mongoose", "prisma",
"@prisma/client", "drizzle-orm",
"jsonwebtoken", "passport", "bcrypt", "bcryptjs",
"ws", "socket.io",
];
fn read_pkg_kind(dir: &Path) -> PkgKind {
let raw = match std::fs::read_to_string(dir.join("package.json")) {
Ok(s) => s,
Err(_) => return PkgKind::Unknown,
};
let all_deps = extract_dep_keys(&raw);
let has_fe = all_deps.iter().any(|d| FRONTEND_DEPS.contains(&d.as_str()));
let has_be = all_deps.iter().any(|d| BACKEND_DEPS.contains(&d.as_str()));
match (has_fe, has_be) {
(true, true) => PkgKind::Fullstack,
(true, false) => PkgKind::Frontend,
(false, true) => PkgKind::Backend,
(false, false) => PkgKind::Unknown,
}
}
fn extract_dep_keys(json: &str) -> Vec<String> {
let mut keys = Vec::new();
for section in ["dependencies", "devDependencies"] {
let start = match json.find(section) {
Some(i) => i,
None => continue,
};
let brace = match json[start..].find('{') {
Some(i) => start + i + 1,
None => continue,
};
let mut depth = 1usize;
let mut pos = brace;
let bytes = json.as_bytes();
while pos < bytes.len() && depth > 0 {
match bytes[pos] {
b'{' => depth += 1,
b'}' => depth -= 1,
_ => {}
}
pos += 1;
}
let block = &json[brace..pos.saturating_sub(1)];
let mut remaining = block;
while let Some(q1) = remaining.find('"') {
remaining = &remaining[q1 + 1..];
let q2 = match remaining.find('"') {
Some(i) => i,
None => break,
};
let key = &remaining[..q2];
remaining = &remaining[q2 + 1..];
let after = remaining.trim_start();
if after.starts_with(':') {
keys.push(key.to_string());
}
}
}
keys
}
fn is_embedded_cargo(path: &Path) -> bool {
if !path.join("Cargo.toml").exists() {
return false;
}
let cfg = path.join(".cargo/config.toml");
if !cfg.exists() {
return false;
}
std::fs::read_to_string(cfg)
.map(|s| s.contains("thumbv") || s.contains("riscv") || s.contains("xtensa"))
.unwrap_or(false)
}
fn has_ext(dir: &Path, ext: &str) -> bool {
std::fs::read_dir(dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.any(|e| e.path().extension().map_or(false, |x| x == ext))
})
.unwrap_or(false)
}