use std::{
fs::read_dir,
path::{Path, PathBuf},
};
use anyhow::{Context, Result};
use clap::{Arg, ArgMatches, Command};
use serde::Serialize;
use serde_json::json;
use crate::{
CliCommand,
core::{
command::command,
manifest::ProjectType,
static_analysis::{
EntityAnalyzer, SchemaAnalyzer,
entity_analyzer::{EntityProperty, RelationType},
},
validate::require_manifest,
},
};
pub(crate) struct AnalyzeCommand;
impl AnalyzeCommand {
pub(crate) fn new() -> Self {
Self
}
}
impl CliCommand for AnalyzeCommand {
fn command(&self) -> Command {
command(
"analyze",
"Emit a structured JSON snapshot of the workspace (modules, entities, schemas). Read-only.",
)
.arg(
Arg::new("base_path")
.short('p')
.long("path")
.help("Application root path (defaults to the current directory's manifest)"),
)
.arg(
Arg::new("format")
.long("format")
.help("Output format")
.value_parser(["json"])
.default_value("json"),
)
.arg(
Arg::new("module")
.short('m')
.long("module")
.help("Filter to a single module by name"),
)
.arg(
Arg::new("pretty")
.long("pretty")
.help("Pretty-print JSON output")
.action(clap::ArgAction::SetTrue),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let (app_root, manifest) = require_manifest(matches)?;
let module_filter = matches.get_one::<String>("module").cloned();
let pretty = matches.get_flag("pretty");
let modules = collect_modules(
&app_root,
&manifest.modules_path,
&manifest.projects,
module_filter.as_deref(),
)?;
let doc = AnalyzeDocument {
app_name: manifest.app_name.clone(),
modules_path: manifest.modules_path.clone(),
modules,
};
let serialized = if pretty {
serde_json::to_string_pretty(&json!(doc))?
} else {
serde_json::to_string(&json!(doc))?
};
println!("{}", serialized);
Ok(())
}
}
#[derive(Serialize)]
struct AnalyzeDocument {
#[serde(rename = "appName")]
app_name: String,
#[serde(rename = "modulesPath")]
modules_path: String,
modules: Vec<ModuleEntry>,
}
#[derive(Serialize)]
struct ModuleEntry {
name: String,
kind: String,
description: Option<String>,
entities: Vec<EntityEntry>,
schemas: Vec<SchemaEntry>,
routers: Vec<String>,
services: Vec<String>,
workers: Vec<String>,
}
#[derive(Serialize)]
struct EntityEntry {
name: String,
fields: Vec<FieldEntry>,
relations: Vec<RelationEntry>,
}
#[derive(Serialize)]
struct FieldEntry {
name: String,
#[serde(rename = "typeName")]
type_name: String,
nullable: bool,
collection: bool,
}
#[derive(Serialize)]
struct RelationEntry {
field: String,
#[serde(rename = "toEntity")]
to_entity: String,
cardinality: String,
}
#[derive(Serialize)]
struct SchemaEntry {
#[serde(rename = "exportName")]
export_name: String,
fields: Vec<String>,
}
fn collect_modules(
app_root: &Path,
modules_path: &str,
projects: &[crate::core::manifest::ProjectEntry],
filter: Option<&str>,
) -> Result<Vec<ModuleEntry>> {
let modules_root = app_root.join(modules_path);
let mut entries = Vec::new();
for project in projects {
if let Some(name) = filter {
if project.name != name {
continue;
}
}
if is_builtin_module(&project.name) {
continue;
}
let module_dir = modules_root.join(&project.name);
if !module_dir.is_dir() {
continue;
}
let kind = match project.r#type {
ProjectType::Service => "service",
ProjectType::Worker => "worker",
ProjectType::Library => "library",
};
let entities = collect_entities(&module_dir).unwrap_or_else(|err| {
eprintln!(
"warning: failed to analyze entities for module '{}': {}",
project.name, err
);
Vec::new()
});
let schemas = collect_schemas(&module_dir).unwrap_or_else(|err| {
eprintln!(
"warning: failed to analyze schemas for module '{}': {}",
project.name, err
);
Vec::new()
});
let routers = project
.routers
.clone()
.unwrap_or_else(|| list_basenames(&module_dir.join("api/routes"), ".routes.ts"));
let services = list_basenames(&module_dir.join("domain/services"), ".service.ts");
let workers = list_workers(&module_dir);
entries.push(ModuleEntry {
name: project.name.clone(),
kind: kind.to_string(),
description: Some(project.description.clone()).filter(|s| !s.is_empty()),
entities,
schemas,
routers,
services,
workers,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
fn is_builtin_module(name: &str) -> bool {
matches!(
name,
"core" | "monitoring" | "client-sdk" | "iam" | "billing" | "messaging"
)
}
fn collect_entities(module_dir: &Path) -> Result<Vec<EntityEntry>> {
let entities_dir = module_dir.join("persistence/entities");
let mut out = Vec::new();
if !entities_dir.is_dir() {
return Ok(out);
}
for entry in
read_dir(&entities_dir).with_context(|| format!("read_dir {}", entities_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.ends_with(".entity.ts"))
.unwrap_or(false)
{
continue;
}
let defs = EntityAnalyzer::parse_entity_file(&path).unwrap_or_else(|err| {
eprintln!("warning: failed to parse {}: {}", path.display(), err);
Vec::new()
});
for def in defs {
let mut fields = Vec::new();
let mut relations = Vec::new();
for property in &def.properties {
match &property.relation_type {
Some(rel) => relations.push(RelationEntry {
field: property.name.clone(),
to_entity: property.type_name.clone(),
cardinality: cardinality_name(rel),
}),
None => fields.push(field_entry(property)),
}
}
out.push(EntityEntry {
name: def.name,
fields,
relations,
});
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn field_entry(property: &EntityProperty) -> FieldEntry {
FieldEntry {
name: property.name.clone(),
type_name: property.type_name.clone(),
nullable: property.is_nullable,
collection: property.is_collection,
}
}
fn cardinality_name(rel: &RelationType) -> String {
match rel {
RelationType::ManyToOne => "N:1".to_string(),
RelationType::OneToMany => "1:N".to_string(),
RelationType::ManyToMany => "M:N".to_string(),
RelationType::OneToOne => "1:1".to_string(),
}
}
fn collect_schemas(module_dir: &Path) -> Result<Vec<SchemaEntry>> {
let schemas_dir = module_dir.join("domain/schemas");
let mut out = Vec::new();
if !schemas_dir.is_dir() {
return Ok(out);
}
for entry in
read_dir(&schemas_dir).with_context(|| format!("read_dir {}", schemas_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.ends_with(".schema.ts"))
.unwrap_or(false)
{
continue;
}
let defs = SchemaAnalyzer::parse_schema_file(&path).unwrap_or_else(|err| {
eprintln!("warning: failed to parse {}: {}", path.display(), err);
Vec::new()
});
for def in defs {
out.push(SchemaEntry {
export_name: def.name,
fields: def.properties.iter().map(|p| p.name.clone()).collect(),
});
}
}
out.sort_by(|a, b| a.export_name.cmp(&b.export_name));
Ok(out)
}
fn list_basenames(dir: &Path, suffix: &str) -> Vec<String> {
let mut out = Vec::new();
let Ok(read) = read_dir(dir) else {
return out;
};
for entry in read.flatten() {
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if !name.ends_with(suffix) {
continue;
}
out.push(name.trim_end_matches(suffix).to_string());
}
out.sort();
out
}
fn list_workers(module_dir: &Path) -> Vec<String> {
let mut out = Vec::new();
let Ok(read) = read_dir(module_dir) else {
return out;
};
for entry in read.flatten() {
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if name == "worker.ts" {
out.push("worker".to_string());
continue;
}
if name.ends_with("-worker.ts") {
out.push(name.trim_end_matches(".ts").to_string());
}
}
out.sort();
out
}
#[allow(dead_code)]
pub(crate) fn analyze_workspace(
app_root: &Path,
modules_path: &str,
projects: &[crate::core::manifest::ProjectEntry],
module_filter: Option<&str>,
) -> Result<serde_json::Value> {
let modules = collect_modules(app_root, modules_path, projects, module_filter)?;
let doc = AnalyzeDocument {
app_name: String::new(),
modules_path: modules_path.to_string(),
modules,
};
Ok(serde_json::to_value(&doc)?)
}
#[allow(dead_code)]
pub(crate) fn module_dir(app_root: &Path, modules_path: &str, module: &str) -> PathBuf {
app_root.join(modules_path).join(module)
}