Skip to main content

cgx_engine/docs/
role.rs

1//! Heuristic role classifier for a source file. Drives the TL;DR badge and
2//! a frontmatter tag in each module note.
3//!
4//! Pure heuristics over path + language + node kinds — no AST inspection.
5
6use crate::graph::FileSummary;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum FileRole {
10    Test,
11    Fixture,
12    Cli,
13    Parser,
14    QueryLayer,
15    Config,
16    Build,
17    Model,
18    View,
19    ApiServer,
20    Mcp,
21    Skill,
22    Entry,
23    Library,
24    Other,
25}
26
27impl FileRole {
28    pub fn label(self) -> &'static str {
29        match self {
30            FileRole::Test => "test",
31            FileRole::Fixture => "fixture",
32            FileRole::Cli => "cli",
33            FileRole::Parser => "parser",
34            FileRole::QueryLayer => "query",
35            FileRole::Config => "config",
36            FileRole::Build => "build",
37            FileRole::Model => "model",
38            FileRole::View => "view",
39            FileRole::ApiServer => "api",
40            FileRole::Mcp => "mcp",
41            FileRole::Skill => "skill",
42            FileRole::Entry => "entry",
43            FileRole::Library => "library",
44            FileRole::Other => "other",
45        }
46    }
47
48    /// One-line description used in the TL;DR headline.
49    pub fn description(self) -> &'static str {
50        match self {
51            FileRole::Test => "Test suite",
52            FileRole::Fixture => "Test fixture",
53            FileRole::Cli => "CLI command / argument plumbing",
54            FileRole::Parser => "Language parser (Tree-sitter front-end)",
55            FileRole::QueryLayer => "Graph query / data-access layer",
56            FileRole::Config => "Configuration & settings",
57            FileRole::Build => "Build / packaging glue",
58            FileRole::Model => "Domain model / data types",
59            FileRole::View => "UI component (renders to screen)",
60            FileRole::ApiServer => "HTTP API server / handler",
61            FileRole::Mcp => "MCP (Model Context Protocol) tool surface",
62            FileRole::Skill => "AI agent skill / instructions",
63            FileRole::Entry => "Program entry point",
64            FileRole::Library => "General-purpose library code",
65            FileRole::Other => "General module",
66        }
67    }
68}
69
70/// Classify a file using path + summary signals.
71pub fn classify(summary: &FileSummary) -> FileRole {
72    let path = summary.path.to_lowercase();
73
74    // Tests & fixtures take priority — they're unambiguous.
75    if path.contains("/tests/")
76        || path.ends_with("_test.go")
77        || path.contains(".test.")
78        || path.ends_with("_test.rs")
79        || path.ends_with("_test.py")
80        || path.contains("/__tests__/")
81    {
82        return if path.contains("/fixtures/") || path.contains("/fixture/") {
83            FileRole::Fixture
84        } else {
85            FileRole::Test
86        };
87    }
88    if path.contains("/fixtures/") || path.contains("/fixture/") {
89        return FileRole::Fixture;
90    }
91
92    // Entry points by convention.
93    let basename = path.rsplit('/').next().unwrap_or(&path);
94    if matches!(
95        basename,
96        "main.rs"
97            | "main.go"
98            | "main.py"
99            | "main.ts"
100            | "main.js"
101            | "lib.rs"
102            | "mod.rs"
103            | "index.ts"
104            | "index.js"
105            | "index.tsx"
106    ) {
107        if basename == "lib.rs" || basename == "mod.rs" {
108            return FileRole::Library;
109        }
110        return FileRole::Entry;
111    }
112
113    // Path-prefix-driven roles.
114    if path.contains("/parsers/") || path.contains("/parser/") || path.ends_with("/parser.rs") {
115        return FileRole::Parser;
116    }
117    if path.contains("/mcp/")
118        || path.contains("mcp-server")
119        || path.contains("/tools.rs") && path.contains("mcp")
120    {
121        return FileRole::Mcp;
122    }
123    if path.contains("/cli/") || path.contains("/cmd/") || path.contains("/commands/") {
124        return FileRole::Cli;
125    }
126    if path.contains("/components/")
127        || path.contains("/views/")
128        || path.contains("/pages/")
129        || path.ends_with(".tsx")
130        || path.ends_with(".jsx")
131    {
132        return FileRole::View;
133    }
134    if path.contains("/api/")
135        || path.contains("/server/")
136        || path.contains("/handlers/")
137        || path.contains("/routes/")
138        || path.ends_with("/serve.rs")
139        || path.ends_with("/server.rs")
140    {
141        return FileRole::ApiServer;
142    }
143    if path.contains("/config")
144        || path.ends_with("config.rs")
145        || path.ends_with("config.ts")
146        || path.ends_with("settings.py")
147        || path.ends_with("conf.py")
148    {
149        return FileRole::Config;
150    }
151    if path.contains("/models/") || path.contains("/model/") || path.contains("/types/") {
152        return FileRole::Model;
153    }
154    if path.ends_with("graph.rs")
155        || path.ends_with(".sql")
156        || path.contains("/db/")
157        || path.ends_with("queries.rs")
158        || path.ends_with("repository.rs")
159        || path.contains("/dao/")
160    {
161        return FileRole::QueryLayer;
162    }
163    if path.contains("/skill") || path.ends_with("skill.rs") {
164        return FileRole::Skill;
165    }
166    if path.contains("build.rs")
167        || path.contains("/scripts/")
168        || path.contains("/build/")
169        || path.contains(".github/")
170    {
171        return FileRole::Build;
172    }
173
174    // Signal-based fallback: lots of exported types and few cross-file callers → library.
175    if summary.exported_count >= 8 && summary.callers.len() <= 3 {
176        return FileRole::Library;
177    }
178
179    FileRole::Other
180}