1use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::Path;
8
9use crate::core::property_graph::CodeGraph;
10use crate::core::tokens::count_tokens;
11use serde_json::{json, Value};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15enum OutputFormat {
16 Text,
17 Json,
18}
19
20fn parse_format(format: Option<&str>) -> Result<OutputFormat, String> {
21 let f = format.unwrap_or("text").trim().to_lowercase();
22 match f.as_str() {
23 "text" => Ok(OutputFormat::Text),
24 "json" => Ok(OutputFormat::Json),
25 _ => Err("Error: format must be text|json".to_string()),
26 }
27}
28
29pub fn handle(action: &str, path: Option<&str>, root: &str, format: Option<&str>) -> String {
30 let fmt = match parse_format(format) {
31 Ok(f) => f,
32 Err(e) => return e,
33 };
34
35 match action {
36 "overview" => handle_overview(root, fmt),
37 "clusters" => handle_clusters(root, fmt),
38 "communities" => handle_communities(root, fmt),
39 "layers" => handle_layers(root, fmt),
40 "cycles" => handle_cycles(root, fmt),
41 "entrypoints" => handle_entrypoints(root, fmt),
42 "hotspots" => handle_hotspots(root, fmt),
43 "health" => handle_health(root, fmt),
44 "module" => handle_module(path, root, fmt),
45 _ => "Unknown action. Use: overview, clusters, communities, layers, cycles, entrypoints, hotspots, health, module"
46 .to_string(),
47 }
48}
49
50fn open_graph(root: &str) -> Result<CodeGraph, String> {
51 CodeGraph::open(root).map_err(|e| format!("Failed to open graph: {e}"))
52}
53
54struct GraphData {
55 forward: HashMap<String, Vec<String>>,
56 reverse: HashMap<String, Vec<String>>,
57 all_files: HashSet<String>,
58}
59
60fn ensure_graph_built(root: &str) {
61 let Ok(graph) = CodeGraph::open(root) else {
62 return;
63 };
64 if graph.node_count().unwrap_or(0) == 0 {
65 drop(graph);
66 let result = crate::tools::ctx_impact::handle("build", None, root, None, None);
67 tracing::info!(
68 "Auto-built graph for architecture: {}",
69 &result[..result.len().min(100)]
70 );
71 }
72}
73
74fn load_graph_data(graph: &CodeGraph) -> Result<GraphData, String> {
75 let nodes = graph.node_count().map_err(|e| format!("{e}"))?;
76 if nodes == 0 {
77 return Err(
78 "Graph is empty after auto-build. No supported source files found.".to_string(),
79 );
80 }
81
82 let conn = &graph.connection();
83 let mut stmt = conn
84 .prepare(
85 "SELECT DISTINCT n_src.file_path, n_tgt.file_path
86 FROM edges e
87 JOIN nodes n_src ON e.source_id = n_src.id
88 JOIN nodes n_tgt ON e.target_id = n_tgt.id
89 WHERE e.kind = 'imports'
90 AND n_src.kind = 'file' AND n_tgt.kind = 'file'
91 AND n_src.file_path != n_tgt.file_path",
92 )
93 .map_err(|e| format!("{e}"))?;
94
95 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
96 let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
97 let mut all_files: HashSet<String> = HashSet::new();
98
99 let rows = stmt
100 .query_map([], |row| {
101 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
102 })
103 .map_err(|e| format!("{e}"))?;
104
105 for row in rows {
106 let (src, tgt) = row.map_err(|e| format!("{e}"))?;
107 all_files.insert(src.clone());
108 all_files.insert(tgt.clone());
109 forward.entry(src.clone()).or_default().push(tgt.clone());
110 reverse.entry(tgt).or_default().push(src);
111 }
112
113 let mut file_stmt = conn
114 .prepare("SELECT DISTINCT file_path FROM nodes WHERE kind = 'file'")
115 .map_err(|e| format!("{e}"))?;
116 let file_rows = file_stmt
117 .query_map([], |row| row.get::<_, String>(0))
118 .map_err(|e| format!("{e}"))?;
119 for f in file_rows.flatten() {
120 all_files.insert(f);
121 }
122
123 for deps in forward.values_mut() {
124 deps.sort();
125 deps.dedup();
126 }
127 for deps in reverse.values_mut() {
128 deps.sort();
129 deps.dedup();
130 }
131
132 Ok(GraphData {
133 forward,
134 reverse,
135 all_files,
136 })
137}
138
139fn handle_overview(root: &str, fmt: OutputFormat) -> String {
140 ensure_graph_built(root);
141
142 let graph = match open_graph(root) {
143 Ok(g) => g,
144 Err(e) => return e,
145 };
146
147 let data = match load_graph_data(&graph) {
148 Ok(d) => d,
149 Err(e) => return e,
150 };
151
152 let clusters = compute_clusters(&data);
153 let layers = compute_layers(&data);
154 let entrypoints = find_entrypoints(&data);
155 let cycles = find_cycles(&data);
156
157 let files_total = data.all_files.len();
158 let import_edges = data.forward.values().map(std::vec::Vec::len).sum::<usize>();
159
160 let clusters_total = clusters.len();
161 let clusters_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CLUSTERS_LIMIT.max(1);
162 let clusters_truncated = clusters_total > clusters_limit;
163
164 let layers_total = layers.len();
165 let layers_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_LAYERS_LIMIT.max(1);
166 let layers_truncated = layers_total > layers_limit;
167
168 let entrypoints_total = entrypoints.len();
169 let entrypoints_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_ENTRYPOINTS_LIMIT.max(1);
170 let entrypoints_truncated = entrypoints_total > entrypoints_limit;
171
172 let cycles_total = cycles.len();
173 let cycles_limit = crate::core::budgets::ARCHITECTURE_OVERVIEW_CYCLES_LIMIT.max(1);
174 let cycles_truncated = cycles_total > cycles_limit;
175
176 match fmt {
177 OutputFormat::Json => {
178 let root_path = Path::new(root);
179 let clusters_json: Vec<Value> = clusters
180 .iter()
181 .take(clusters_limit)
182 .map(|c| {
183 json!({
184 "dir": common_prefix(&c.files),
185 "file_count": c.files.len(),
186 "internal_edges": c.internal_edges
187 })
188 })
189 .collect();
190
191 let layers_json: Vec<Value> = layers
192 .iter()
193 .take(layers_limit)
194 .map(|l| {
195 json!({
196 "depth": l.depth,
197 "file_count": l.files.len()
198 })
199 })
200 .collect();
201
202 let entrypoints_json: Vec<Value> = entrypoints
203 .iter()
204 .take(entrypoints_limit)
205 .map(|ep| {
206 let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
207 json!({ "file": ep, "imports": imports })
208 })
209 .collect();
210
211 let cycles_json: Vec<Value> = cycles
212 .iter()
213 .take(cycles_limit)
214 .map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
215 .collect();
216
217 let v = json!({
218 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
219 "tool": "ctx_architecture",
220 "action": "overview",
221 "project": project_meta(root),
222 "graph": graph_summary(root_path),
223 "graph_meta": crate::core::property_graph::load_meta(root),
224 "files_total": files_total,
225 "import_edges": import_edges,
226 "clusters_total": clusters_total,
227 "clusters": clusters_json,
228 "clusters_truncated": clusters_truncated,
229 "layers_total": layers_total,
230 "layers": layers_json,
231 "layers_truncated": layers_truncated,
232 "entrypoints_total": entrypoints_total,
233 "entrypoints": entrypoints_json,
234 "entrypoints_truncated": entrypoints_truncated,
235 "cycles_total": cycles_total,
236 "cycles": cycles_json,
237 "cycles_truncated": cycles_truncated
238 });
239 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
240 }
241 OutputFormat::Text => {
242 let mut result = format!(
243 "Architecture Overview ({files_total} files, {import_edges} import edges)\n"
244 );
245
246 result.push_str(&format!("\nClusters: {clusters_total}\n"));
247 for (i, cluster) in clusters.iter().enumerate().take(clusters_limit) {
248 let dir = common_prefix(&cluster.files);
249 result.push_str(&format!(
250 " #{}: {} files ({})\n",
251 i + 1,
252 cluster.files.len(),
253 dir
254 ));
255 }
256 if clusters_truncated {
257 result.push_str(&format!(
258 " ... +{} more\n",
259 clusters_total - clusters_limit
260 ));
261 }
262
263 result.push_str(&format!("\nLayers: {layers_total}\n"));
264 for layer in layers.iter().take(layers_limit) {
265 result.push_str(&format!(
266 " L{}: {} files\n",
267 layer.depth,
268 layer.files.len()
269 ));
270 }
271 if layers_truncated {
272 result.push_str(&format!(" ... +{} more\n", layers_total - layers_limit));
273 }
274
275 result.push_str(&format!("\nEntrypoints: {entrypoints_total}\n"));
276 for ep in entrypoints.iter().take(entrypoints_limit) {
277 result.push_str(&format!(" {ep}\n"));
278 }
279 if entrypoints_truncated {
280 result.push_str(&format!(
281 " ... +{} more\n",
282 entrypoints_total - entrypoints_limit
283 ));
284 }
285
286 result.push_str(&format!("\nCycles: {cycles_total}\n"));
287 for cycle in cycles.iter().take(cycles_limit) {
288 result.push_str(&format!(" {}\n", cycle.join(" -> ")));
289 }
290 if cycles_truncated {
291 result.push_str(&format!(" ... +{} more\n", cycles_total - cycles_limit));
292 }
293
294 let tokens = count_tokens(&result);
295 format!("{result}[ctx_architecture: {tokens} tok]")
296 }
297 }
298}
299
300fn handle_clusters(root: &str, fmt: OutputFormat) -> String {
301 ensure_graph_built(root);
302 let graph = match open_graph(root) {
303 Ok(g) => g,
304 Err(e) => return e,
305 };
306
307 let data = match load_graph_data(&graph) {
308 Ok(d) => d,
309 Err(e) => return e,
310 };
311
312 let clusters = compute_clusters(&data);
313 let total = clusters.len();
314 let limit = crate::core::budgets::ARCHITECTURE_CLUSTERS_LIMIT.max(1);
315 let file_limit = crate::core::budgets::ARCHITECTURE_CLUSTER_FILES_LIMIT.max(1);
316 let truncated = total > limit;
317
318 match fmt {
319 OutputFormat::Json => {
320 let root_path = Path::new(root);
321 let items: Vec<Value> = clusters
322 .iter()
323 .take(limit)
324 .map(|c| {
325 let dir = common_prefix(&c.files);
326 let files_total = c.files.len();
327 let files_truncated = files_total > file_limit;
328 let mut files = c.files.clone();
329 if files_truncated {
330 files.truncate(file_limit);
331 }
332 json!({
333 "dir": dir,
334 "file_count": files_total,
335 "internal_edges": c.internal_edges,
336 "files": files,
337 "files_truncated": files_truncated
338 })
339 })
340 .collect();
341 let v = json!({
342 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
343 "tool": "ctx_architecture",
344 "action": "clusters",
345 "project": project_meta(root),
346 "graph": graph_summary(root_path),
347 "graph_meta": crate::core::property_graph::load_meta(root),
348 "clusters_total": total,
349 "clusters": items,
350 "truncated": truncated
351 });
352 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
353 }
354 OutputFormat::Text => {
355 let mut result = format!("Module Clusters ({total}):\n");
356
357 for (i, cluster) in clusters.iter().take(limit).enumerate() {
358 let dir = common_prefix(&cluster.files);
359 result.push_str(&format!(
360 "\n#{} — {} ({} files, {} internal edges)\n",
361 i + 1,
362 dir,
363 cluster.files.len(),
364 cluster.internal_edges
365 ));
366 for file in cluster.files.iter().take(file_limit) {
367 result.push_str(&format!(" {file}\n"));
368 }
369 if cluster.files.len() > file_limit {
370 result.push_str(&format!(
371 " ... +{} more\n",
372 cluster.files.len() - file_limit
373 ));
374 }
375 }
376 if truncated {
377 result.push_str(&format!("\n... +{} more clusters\n", total - limit));
378 }
379
380 let tokens = count_tokens(&result);
381 format!("{result}[ctx_architecture clusters: {tokens} tok]")
382 }
383 }
384}
385
386fn handle_communities(root: &str, fmt: OutputFormat) -> String {
387 ensure_graph_built(root);
388 let graph = match open_graph(root) {
389 Ok(g) => g,
390 Err(e) => return e,
391 };
392
393 let result = crate::core::community::detect_communities(graph.connection());
394
395 match fmt {
396 OutputFormat::Json => {
397 let root_path = Path::new(root);
398 let comms: Vec<Value> = result
399 .communities
400 .iter()
401 .take(30)
402 .map(|c| {
403 json!({
404 "id": c.id,
405 "file_count": c.files.len(),
406 "files": c.files.iter().take(20).collect::<Vec<_>>(),
407 "internal_edges": c.internal_edges,
408 "external_edges": c.external_edges,
409 "cohesion": (c.cohesion * 100.0).round() / 100.0,
410 })
411 })
412 .collect();
413 let v = json!({
414 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
415 "tool": "ctx_architecture",
416 "action": "communities",
417 "project": project_meta(root),
418 "graph": graph_summary(root_path),
419 "modularity": (result.modularity * 1000.0).round() / 1000.0,
420 "node_count": result.node_count,
421 "edge_count": result.edge_count,
422 "community_count": result.communities.len(),
423 "communities": comms
424 });
425 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
426 }
427 OutputFormat::Text => {
428 let mut out = format!(
429 "Community Detection (Louvain) — {} communities, modularity {:.3}\n\n",
430 result.communities.len(),
431 result.modularity
432 );
433 for c in result.communities.iter().take(20) {
434 out.push_str(&format!(
435 " Community #{}: {} files, cohesion {:.0}%, {} internal / {} external edges\n",
436 c.id,
437 c.files.len(),
438 c.cohesion * 100.0,
439 c.internal_edges,
440 c.external_edges
441 ));
442 for f in c.files.iter().take(10) {
443 out.push_str(&format!(" {f}\n"));
444 }
445 if c.files.len() > 10 {
446 out.push_str(&format!(" ... +{} more\n", c.files.len() - 10));
447 }
448 }
449 if result.communities.len() > 20 {
450 out.push_str(&format!(
451 "\n ... +{} more communities\n",
452 result.communities.len() - 20
453 ));
454 }
455 let tokens = count_tokens(&out);
456 format!("{out}\n[ctx_architecture communities: {tokens} tok]")
457 }
458 }
459}
460
461fn handle_layers(root: &str, fmt: OutputFormat) -> String {
462 ensure_graph_built(root);
463 let graph = match open_graph(root) {
464 Ok(g) => g,
465 Err(e) => return e,
466 };
467
468 let data = match load_graph_data(&graph) {
469 Ok(d) => d,
470 Err(e) => return e,
471 };
472
473 let layers = compute_layers(&data);
474 let total = layers.len();
475 let limit = crate::core::budgets::ARCHITECTURE_LAYERS_LIMIT.max(1);
476 let file_limit = crate::core::budgets::ARCHITECTURE_LAYER_FILES_LIMIT.max(1);
477 let truncated = total > limit;
478
479 match fmt {
480 OutputFormat::Json => {
481 let root_path = Path::new(root);
482 let items: Vec<Value> = layers
483 .iter()
484 .take(limit)
485 .map(|l| {
486 let files_total = l.files.len();
487 let files_truncated = files_total > file_limit;
488 let mut files = l.files.clone();
489 if files_truncated {
490 files.truncate(file_limit);
491 }
492 json!({
493 "depth": l.depth,
494 "file_count": files_total,
495 "files": files,
496 "files_truncated": files_truncated
497 })
498 })
499 .collect();
500 let v = json!({
501 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
502 "tool": "ctx_architecture",
503 "action": "layers",
504 "project": project_meta(root),
505 "graph": graph_summary(root_path),
506 "graph_meta": crate::core::property_graph::load_meta(root),
507 "layers_total": total,
508 "layers": items,
509 "truncated": truncated
510 });
511 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
512 }
513 OutputFormat::Text => {
514 let mut result = format!("Dependency Layers ({total}):\n");
515
516 for layer in layers.iter().take(limit) {
517 result.push_str(&format!(
518 "\nLayer {} ({} files):\n",
519 layer.depth,
520 layer.files.len()
521 ));
522 for file in layer.files.iter().take(file_limit) {
523 result.push_str(&format!(" {file}\n"));
524 }
525 if layer.files.len() > file_limit {
526 result.push_str(&format!(" ... +{} more\n", layer.files.len() - file_limit));
527 }
528 }
529 if truncated {
530 result.push_str(&format!("\n... +{} more layers\n", total - limit));
531 }
532
533 let tokens = count_tokens(&result);
534 format!("{result}[ctx_architecture layers: {tokens} tok]")
535 }
536 }
537}
538
539fn handle_cycles(root: &str, fmt: OutputFormat) -> String {
540 ensure_graph_built(root);
541 let graph = match open_graph(root) {
542 Ok(g) => g,
543 Err(e) => return e,
544 };
545
546 let data = match load_graph_data(&graph) {
547 Ok(d) => d,
548 Err(e) => return e,
549 };
550
551 let cycles = find_cycles(&data);
552 if cycles.is_empty() {
553 return match fmt {
554 OutputFormat::Json => {
555 let root_path = Path::new(root);
556 let v = json!({
557 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
558 "tool": "ctx_architecture",
559 "action": "cycles",
560 "project": project_meta(root),
561 "graph": graph_summary(root_path),
562 "graph_meta": crate::core::property_graph::load_meta(root),
563 "cycles_total": 0,
564 "cycles": []
565 });
566 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
567 }
568 OutputFormat::Text => "No dependency cycles found.".to_string(),
569 };
570 }
571
572 let total = cycles.len();
573 let limit = crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1);
574 let truncated = total > limit;
575
576 match fmt {
577 OutputFormat::Json => {
578 let root_path = Path::new(root);
579 let items: Vec<Value> = cycles
580 .iter()
581 .take(limit)
582 .map(|c| json!({ "path": c, "len": c.len().saturating_sub(1) }))
583 .collect();
584 let v = json!({
585 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
586 "tool": "ctx_architecture",
587 "action": "cycles",
588 "project": project_meta(root),
589 "graph": graph_summary(root_path),
590 "graph_meta": crate::core::property_graph::load_meta(root),
591 "cycles_total": total,
592 "cycles": items,
593 "truncated": truncated
594 });
595 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
596 }
597 OutputFormat::Text => {
598 let mut result = format!("Dependency Cycles ({total}):\n");
599 for (i, cycle) in cycles.iter().take(limit).enumerate() {
600 result.push_str(&format!("\n#{}: {}\n", i + 1, cycle.join(" -> ")));
601 }
602 if truncated {
603 result.push_str(&format!("\n... +{} more cycles\n", total - limit));
604 }
605
606 let tokens = count_tokens(&result);
607 format!("{result}[ctx_architecture cycles: {tokens} tok]")
608 }
609 }
610}
611
612fn handle_entrypoints(root: &str, fmt: OutputFormat) -> String {
613 ensure_graph_built(root);
614 let graph = match open_graph(root) {
615 Ok(g) => g,
616 Err(e) => return e,
617 };
618
619 let data = match load_graph_data(&graph) {
620 Ok(d) => d,
621 Err(e) => return e,
622 };
623
624 let entrypoints = find_entrypoints(&data);
625 let total = entrypoints.len();
626 let limit = crate::core::budgets::ARCHITECTURE_ENTRYPOINTS_LIMIT.max(1);
627 let truncated = total > limit;
628
629 match fmt {
630 OutputFormat::Json => {
631 let root_path = Path::new(root);
632 let items: Vec<Value> = entrypoints
633 .iter()
634 .take(limit)
635 .map(|ep| {
636 let imports = data.forward.get(ep).map_or(0, std::vec::Vec::len);
637 json!({ "file": ep, "imports": imports })
638 })
639 .collect();
640 let v = json!({
641 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
642 "tool": "ctx_architecture",
643 "action": "entrypoints",
644 "project": project_meta(root),
645 "graph": graph_summary(root_path),
646 "graph_meta": crate::core::property_graph::load_meta(root),
647 "entrypoints_total": total,
648 "entrypoints": items,
649 "truncated": truncated
650 });
651 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
652 }
653 OutputFormat::Text => {
654 let mut result = format!("Entrypoints ({total} — files with no dependents):\n");
655 for ep in entrypoints.iter().take(limit) {
656 let dep_count = data.forward.get(ep).map_or(0, std::vec::Vec::len);
657 result.push_str(&format!(" {ep} (imports {dep_count} files)\n"));
658 }
659 if truncated {
660 result.push_str(&format!(" ... +{} more\n", total - limit));
661 }
662
663 let tokens = count_tokens(&result);
664 format!("{result}[ctx_architecture entrypoints: {tokens} tok]")
665 }
666 }
667}
668
669fn handle_hotspots(root: &str, fmt: OutputFormat) -> String {
670 ensure_graph_built(root);
671 let graph = match open_graph(root) {
672 Ok(g) => g,
673 Err(e) => return e,
674 };
675
676 let data = match load_graph_data(&graph) {
677 Ok(d) => d,
678 Err(e) => return e,
679 };
680
681 let pr_input = crate::core::pagerank::PageRankInput {
682 files: data.all_files.clone(),
683 forward: data.forward.clone(),
684 };
685 let pagerank = crate::core::pagerank::compute(&pr_input, 0.85, 30);
686 let cfg = crate::core::smells::SmellConfig::default();
687 let findings = crate::core::smells::scan_all(graph.connection(), &cfg);
688
689 let mut smell_count: HashMap<String, usize> = HashMap::new();
690 for f in &findings {
691 *smell_count.entry(f.file_path.clone()).or_default() += 1;
692 }
693
694 let mut hotspots: Vec<(String, f64, f64, usize, usize)> = pagerank
695 .iter()
696 .map(|(file, &rank)| {
697 let in_edges = data.reverse.get(file).map_or(0, Vec::len);
698 let out_edges = data.forward.get(file).map_or(0, Vec::len);
699 let smells = smell_count.get(file).copied().unwrap_or(0);
700 let score = rank * 0.4
701 + (in_edges + out_edges) as f64 * 0.01 * 0.3
702 + smells as f64 * 0.05 * 0.3;
703 (file.clone(), score, rank, in_edges + out_edges, smells)
704 })
705 .collect();
706
707 hotspots.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
708
709 let limit = 30;
710 match fmt {
711 OutputFormat::Json => {
712 let items: Vec<Value> = hotspots
713 .iter()
714 .take(limit)
715 .map(|(file, score, rank, edges, smells)| {
716 json!({
717 "file": file,
718 "score": (score * 1000.0).round() / 1000.0,
719 "pagerank": (rank * 10000.0).round() / 10000.0,
720 "edges": edges,
721 "smells": smells
722 })
723 })
724 .collect();
725 let v = json!({
726 "tool": "ctx_architecture",
727 "action": "hotspots",
728 "total_files": data.all_files.len(),
729 "hotspots": items
730 });
731 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
732 }
733 OutputFormat::Text => {
734 let mut result = format!(
735 "Hotspots ({} files analyzed)\n\n {:<50} {:>8} {:>8} {:>6} {:>6}\n",
736 data.all_files.len(),
737 "File",
738 "Score",
739 "PageRank",
740 "Edges",
741 "Smells"
742 );
743 result.push_str(&format!(" {}\n", "-".repeat(82)));
744 for (file, score, rank, edges, smells) in hotspots.iter().take(limit) {
745 let display = if file.len() > 48 {
746 format!("...{}", &file[file.len() - 45..])
747 } else {
748 file.clone()
749 };
750 result.push_str(&format!(
751 " {display:<50} {score:>8.3} {rank:>8.4} {edges:>6} {smells:>6}\n"
752 ));
753 }
754 if hotspots.len() > limit {
755 result.push_str(&format!("\n ... +{} more\n", hotspots.len() - limit));
756 }
757 let tokens = count_tokens(&result);
758 format!("{result}\n[ctx_architecture hotspots: {tokens} tok]")
759 }
760 }
761}
762
763fn handle_health(root: &str, fmt: OutputFormat) -> String {
764 ensure_graph_built(root);
765 let graph = match open_graph(root) {
766 Ok(g) => g,
767 Err(e) => return e,
768 };
769
770 let data = match load_graph_data(&graph) {
771 Ok(d) => d,
772 Err(e) => return e,
773 };
774
775 let communities = crate::core::community::detect_communities(graph.connection());
776 let cfg = crate::core::smells::SmellConfig::default();
777 let findings = crate::core::smells::scan_all(graph.connection(), &cfg);
778 let summary = crate::core::smells::summarize(&findings);
779 let cycles = find_cycles(&data);
780 let layers = compute_layers(&data);
781
782 let total_smells: usize = summary.iter().map(|s| s.findings).sum();
783 let files = data.all_files.len();
784 let edges = data.forward.values().map(Vec::len).sum::<usize>();
785
786 let smell_density = if files > 0 {
787 total_smells as f64 / files as f64
788 } else {
789 0.0
790 };
791 let avg_cohesion = if communities.communities.is_empty() {
792 0.0
793 } else {
794 communities
795 .communities
796 .iter()
797 .map(|c| c.cohesion)
798 .sum::<f64>()
799 / communities.communities.len() as f64
800 };
801
802 let health_score = compute_health_score(
803 smell_density,
804 avg_cohesion,
805 communities.modularity,
806 cycles.len(),
807 files,
808 );
809
810 let grade = match health_score {
811 s if s >= 90.0 => "A",
812 s if s >= 80.0 => "B",
813 s if s >= 65.0 => "C",
814 s if s >= 50.0 => "D",
815 _ => "F",
816 };
817
818 match fmt {
819 OutputFormat::Json => {
820 let smell_items: Vec<Value> = summary
821 .iter()
822 .filter(|s| s.findings > 0)
823 .map(|s| json!({"rule": s.rule, "findings": s.findings}))
824 .collect();
825 let v = json!({
826 "tool": "ctx_architecture",
827 "action": "health",
828 "health_score": (health_score * 10.0).round() / 10.0,
829 "grade": grade,
830 "files": files,
831 "edges": edges,
832 "total_smells": total_smells,
833 "smell_density": (smell_density * 100.0).round() / 100.0,
834 "modularity": (communities.modularity * 1000.0).round() / 1000.0,
835 "avg_cohesion": (avg_cohesion * 100.0).round() / 100.0,
836 "communities": communities.communities.len(),
837 "cycles": cycles.len(),
838 "layers": layers.len(),
839 "smells_by_rule": smell_items
840 });
841 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
842 }
843 OutputFormat::Text => {
844 let mut result = format!(
845 "Architecture Health Report\n\n Score: {health_score:.0}/100 (Grade: {grade})\n Files: {files}\n Edges: {edges}\n"
846 );
847 result.push_str(&format!(
848 " Communities: {} (modularity {:.3}, avg cohesion {:.0}%)\n",
849 communities.communities.len(),
850 communities.modularity,
851 avg_cohesion * 100.0
852 ));
853 result.push_str(&format!(
854 " Cycles: {}\n Layers: {}\n Smells: {} (density {:.2}/file)\n",
855 cycles.len(),
856 layers.len(),
857 total_smells,
858 smell_density
859 ));
860
861 if total_smells > 0 {
862 result.push_str("\n Smell breakdown:\n");
863 for s in &summary {
864 if s.findings > 0 {
865 result.push_str(&format!(" {:<25} {:>3}\n", s.rule, s.findings));
866 }
867 }
868 }
869
870 let tokens = count_tokens(&result);
871 format!("{result}\n[ctx_architecture health: {tokens} tok]")
872 }
873 }
874}
875
876fn compute_health_score(
877 smell_density: f64,
878 avg_cohesion: f64,
879 modularity: f64,
880 cycle_count: usize,
881 file_count: usize,
882) -> f64 {
883 let smell_penalty = (smell_density * 10.0).min(30.0);
884 let cohesion_bonus = avg_cohesion * 20.0;
885 let modularity_bonus = modularity.max(0.0) * 30.0;
886 let cycle_penalty = (cycle_count as f64 * 5.0).min(20.0);
887 let size_factor = if file_count > 1000 { 0.95 } else { 1.0 };
888
889 let raw =
890 (50.0 + cohesion_bonus + modularity_bonus - smell_penalty - cycle_penalty) * size_factor;
891 raw.clamp(0.0, 100.0)
892}
893
894fn handle_module(path: Option<&str>, root: &str, fmt: OutputFormat) -> String {
895 let Some(target) = path else {
896 return "path is required for 'module' action".to_string();
897 };
898
899 ensure_graph_built(root);
900 let graph = match open_graph(root) {
901 Ok(g) => g,
902 Err(e) => return e,
903 };
904
905 let data = match load_graph_data(&graph) {
906 Ok(d) => d,
907 Err(e) => return e,
908 };
909
910 let canon_root = crate::core::pathutil::safe_canonicalize(std::path::Path::new(root))
911 .map_or_else(|_| root.to_string(), |p| p.to_string_lossy().to_string());
912 let canon_target = crate::core::pathutil::safe_canonicalize(std::path::Path::new(target))
913 .map_or_else(|_| target.to_string(), |p| p.to_string_lossy().to_string());
914 let root_slash = if canon_root.ends_with('/') {
915 canon_root.clone()
916 } else {
917 format!("{canon_root}/")
918 };
919 let rel = canon_target
920 .strip_prefix(&root_slash)
921 .or_else(|| canon_target.strip_prefix(&canon_root))
922 .unwrap_or(&canon_target)
923 .trim_start_matches('/');
924
925 let prefix = if rel.contains('/') {
926 rel.rsplitn(2, '/').last().unwrap_or(rel)
927 } else {
928 rel
929 };
930
931 let mut module_files: Vec<String> = data
932 .all_files
933 .iter()
934 .filter(|f| f.starts_with(prefix))
935 .cloned()
936 .collect();
937 module_files.sort();
938
939 if module_files.is_empty() {
940 return format!("No files found in module path '{prefix}'");
941 }
942
943 let file_set: HashSet<&str> = module_files
944 .iter()
945 .map(std::string::String::as_str)
946 .collect();
947
948 let mut internal_edges = 0;
949 let mut external_imports: Vec<String> = Vec::new();
950 let mut external_dependents: Vec<String> = Vec::new();
951
952 for file in &module_files {
953 if let Some(deps) = data.forward.get(file) {
954 for dep in deps {
955 if file_set.contains(dep.as_str()) {
956 internal_edges += 1;
957 } else {
958 external_imports.push(format!("{file} -> {dep}"));
959 }
960 }
961 }
962 if let Some(revs) = data.reverse.get(file) {
963 for rev in revs {
964 if !file_set.contains(rev.as_str()) {
965 external_dependents.push(format!("{rev} -> {file}"));
966 }
967 }
968 }
969 }
970
971 external_imports.sort();
972 external_imports.dedup();
973 external_dependents.sort();
974 external_dependents.dedup();
975
976 let files_total = module_files.len();
977 let file_limit = crate::core::budgets::ARCHITECTURE_MODULE_FILES_LIMIT.max(1);
978 let files_truncated = files_total > file_limit;
979
980 match fmt {
981 OutputFormat::Json => {
982 let root_path = Path::new(root);
983 let files: Vec<String> = module_files.iter().take(file_limit).cloned().collect();
984
985 let ext_limit = 50usize;
986 let ext_imports_total = external_imports.len();
987 let ext_dependents_total = external_dependents.len();
988 let imports_truncated = ext_imports_total > ext_limit;
989 let dependents_truncated = ext_dependents_total > ext_limit;
990 let imports: Vec<String> = external_imports.iter().take(ext_limit).cloned().collect();
991 let dependents: Vec<String> = external_dependents
992 .iter()
993 .take(ext_limit)
994 .cloned()
995 .collect();
996
997 let v = json!({
998 "schema_version": crate::core::contracts::GRAPH_REPRODUCIBILITY_V1_SCHEMA_VERSION,
999 "tool": "ctx_architecture",
1000 "action": "module",
1001 "project": project_meta(root),
1002 "graph": graph_summary(root_path),
1003 "graph_meta": crate::core::property_graph::load_meta(root),
1004 "module_prefix": prefix,
1005 "file_count": files_total,
1006 "internal_edges": internal_edges,
1007 "files": files,
1008 "files_truncated": files_truncated,
1009 "external_imports_total": ext_imports_total,
1010 "external_imports": imports,
1011 "external_imports_truncated": imports_truncated,
1012 "external_dependents_total": ext_dependents_total,
1013 "external_dependents": dependents,
1014 "external_dependents_truncated": dependents_truncated
1015 });
1016 serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".to_string())
1017 }
1018 OutputFormat::Text => {
1019 let mut result = format!(
1020 "Module '{prefix}' ({files_total} files, {internal_edges} internal edges)\n"
1021 );
1022
1023 result.push_str("\nFiles:\n");
1024 for f in module_files.iter().take(file_limit) {
1025 result.push_str(&format!(" {f}\n"));
1026 }
1027 if files_truncated {
1028 result.push_str(&format!(" ... +{} more\n", files_total - file_limit));
1029 }
1030
1031 if !external_imports.is_empty() {
1032 result.push_str(&format!(
1033 "\nExternal imports ({}):\n",
1034 external_imports.len()
1035 ));
1036 for imp in external_imports.iter().take(15) {
1037 result.push_str(&format!(" {imp}\n"));
1038 }
1039 if external_imports.len() > 15 {
1040 result.push_str(&format!(" ... +{} more\n", external_imports.len() - 15));
1041 }
1042 }
1043
1044 if !external_dependents.is_empty() {
1045 result.push_str(&format!(
1046 "\nExternal dependents ({}):\n",
1047 external_dependents.len()
1048 ));
1049 for dep in external_dependents.iter().take(15) {
1050 result.push_str(&format!(" {dep}\n"));
1051 }
1052 if external_dependents.len() > 15 {
1053 result.push_str(&format!(" ... +{} more\n", external_dependents.len() - 15));
1054 }
1055 }
1056
1057 let tokens = count_tokens(&result);
1058 format!("{result}[ctx_architecture module: {tokens} tok]")
1059 }
1060 }
1061}
1062
1063#[derive(Debug)]
1068struct Cluster {
1069 files: Vec<String>,
1070 internal_edges: usize,
1071}
1072
1073fn compute_clusters(data: &GraphData) -> Vec<Cluster> {
1074 let mut dir_groups: HashMap<String, Vec<String>> = HashMap::new();
1075 for file in &data.all_files {
1076 let dir = file.rsplitn(2, '/').last().unwrap_or("").to_string();
1077 dir_groups.entry(dir).or_default().push(file.clone());
1078 }
1079
1080 let mut clusters: Vec<Cluster> = Vec::new();
1081 for files in dir_groups.values() {
1082 if files.len() < 2 {
1083 continue;
1084 }
1085 let file_set: HashSet<&str> = files.iter().map(std::string::String::as_str).collect();
1086 let mut internal = 0;
1087 for file in files {
1088 if let Some(deps) = data.forward.get(file) {
1089 for dep in deps {
1090 if file_set.contains(dep.as_str()) {
1091 internal += 1;
1092 }
1093 }
1094 }
1095 }
1096
1097 let mut sorted = files.clone();
1098 sorted.sort();
1099 clusters.push(Cluster {
1100 files: sorted,
1101 internal_edges: internal,
1102 });
1103 }
1104
1105 clusters.sort_by(|a, b| {
1106 b.files
1107 .len()
1108 .cmp(&a.files.len())
1109 .then_with(|| a.files[0].cmp(&b.files[0]))
1110 });
1111 clusters
1112}
1113
1114struct Layer {
1115 depth: usize,
1116 files: Vec<String>,
1117}
1118
1119fn compute_layers(data: &GraphData) -> Vec<Layer> {
1120 let leaf_files: HashSet<&String> = data
1121 .all_files
1122 .iter()
1123 .filter(|f| data.forward.get(*f).is_none_or(std::vec::Vec::is_empty))
1124 .collect();
1125
1126 let mut depth_map: HashMap<String, usize> = HashMap::new();
1127 let mut queue: VecDeque<(String, usize)> = VecDeque::new();
1128
1129 for leaf in &leaf_files {
1130 depth_map.insert((*leaf).clone(), 0);
1131 queue.push_back(((*leaf).clone(), 0));
1132 }
1133
1134 while let Some((file, depth)) = queue.pop_front() {
1135 if let Some(dependents) = data.reverse.get(&file) {
1136 for dep in dependents {
1137 let new_depth = depth + 1;
1138 let current = depth_map.get(dep).copied().unwrap_or(0);
1139 if new_depth > current {
1140 depth_map.insert(dep.clone(), new_depth);
1141 queue.push_back((dep.clone(), new_depth));
1142 }
1143 }
1144 }
1145 }
1146
1147 for file in &data.all_files {
1148 depth_map.entry(file.clone()).or_insert(0);
1149 }
1150
1151 let max_depth = depth_map.values().copied().max().unwrap_or(0);
1152 let mut layers: Vec<Layer> = Vec::new();
1153 for d in 0..=max_depth {
1154 let mut files: Vec<String> = depth_map
1155 .iter()
1156 .filter(|(_, &depth)| depth == d)
1157 .map(|(f, _)| f.clone())
1158 .collect();
1159 if !files.is_empty() {
1160 files.sort();
1161 layers.push(Layer { depth: d, files });
1162 }
1163 }
1164
1165 layers
1166}
1167
1168fn find_entrypoints(data: &GraphData) -> Vec<String> {
1169 let mut entrypoints: Vec<String> = data
1170 .all_files
1171 .iter()
1172 .filter(|f| !data.reverse.contains_key(*f))
1173 .cloned()
1174 .collect();
1175 entrypoints.sort();
1176 entrypoints
1177}
1178
1179fn find_cycles(data: &GraphData) -> Vec<Vec<String>> {
1180 let mut cycles: Vec<Vec<String>> = Vec::new();
1181 let mut visited: HashSet<String> = HashSet::new();
1182
1183 let mut starts: Vec<&String> = data.all_files.iter().collect();
1184 starts.sort();
1185 for start in starts {
1186 if visited.contains(start) {
1187 continue;
1188 }
1189
1190 let mut stack: Vec<String> = Vec::new();
1191 let mut on_stack: HashSet<String> = HashSet::new();
1192 dfs_cycles(
1193 start,
1194 &data.forward,
1195 &mut stack,
1196 &mut on_stack,
1197 &mut visited,
1198 &mut cycles,
1199 );
1200 }
1201
1202 cycles.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
1203 cycles.truncate(crate::core::budgets::ARCHITECTURE_CYCLES_LIMIT.max(1));
1204 cycles
1205}
1206
1207fn dfs_cycles(
1208 node: &str,
1209 graph: &HashMap<String, Vec<String>>,
1210 stack: &mut Vec<String>,
1211 on_stack: &mut HashSet<String>,
1212 visited: &mut HashSet<String>,
1213 cycles: &mut Vec<Vec<String>>,
1214) {
1215 if on_stack.contains(node) {
1216 let cycle_start = stack.iter().position(|n| n == node).unwrap_or(0);
1217 let mut cycle: Vec<String> = stack[cycle_start..].to_vec();
1218 cycle.push(node.to_string());
1219 cycles.push(cycle);
1220 return;
1221 }
1222
1223 if visited.contains(node) {
1224 return;
1225 }
1226
1227 on_stack.insert(node.to_string());
1228 stack.push(node.to_string());
1229
1230 if let Some(deps) = graph.get(node) {
1231 for dep in deps {
1232 dfs_cycles(dep, graph, stack, on_stack, visited, cycles);
1233 }
1234 }
1235
1236 stack.pop();
1237 on_stack.remove(node);
1238 visited.insert(node.to_string());
1239}
1240
1241fn common_prefix(files: &[String]) -> String {
1242 if files.is_empty() {
1243 return String::new();
1244 }
1245 if files.len() == 1 {
1246 return files[0]
1247 .rsplitn(2, '/')
1248 .last()
1249 .unwrap_or(&files[0])
1250 .to_string();
1251 }
1252
1253 let parts: Vec<Vec<&str>> = files.iter().map(|f| f.split('/').collect()).collect();
1254 let min_len = parts.iter().map(std::vec::Vec::len).min().unwrap_or(0);
1255
1256 let mut common = Vec::new();
1257 for i in 0..min_len {
1258 let segment = parts[0][i];
1259 if parts.iter().all(|p| p[i] == segment) {
1260 common.push(segment);
1261 } else {
1262 break;
1263 }
1264 }
1265
1266 if common.is_empty() {
1267 "(root)".to_string()
1268 } else {
1269 common.join("/")
1270 }
1271}
1272
1273fn project_meta(root: &str) -> Value {
1274 let root_hash = crate::core::project_hash::hash_project_root(root);
1275 let identity_hash = crate::core::project_hash::project_identity(root)
1276 .as_deref()
1277 .map(crate::core::hasher::hash_str);
1278
1279 let root_path = Path::new(root);
1280 json!({
1281 "project_root_hash": root_hash,
1282 "project_identity_hash": identity_hash,
1283 "git": {
1284 "head": git_out(root_path, &["rev-parse", "--short", "HEAD"]),
1285 "branch": git_out(root_path, &["rev-parse", "--abbrev-ref", "HEAD"]),
1286 "dirty": git_dirty(root_path)
1287 }
1288 })
1289}
1290
1291fn graph_summary(project_root: &Path) -> Value {
1292 let root_str = project_root.to_string_lossy();
1293 let graph_dir = crate::core::property_graph::graph_dir(&root_str);
1294 let db_path = graph_dir.join("graph.db");
1295 let db_path_display = db_path.display().to_string();
1296 if !db_path.exists() {
1297 return json!({
1298 "exists": false,
1299 "db_path": db_path_display,
1300 "nodes": null,
1301 "edges": null
1302 });
1303 }
1304 match CodeGraph::open(&root_str) {
1305 Ok(g) => json!({
1306 "exists": true,
1307 "db_path": g.db_path().display().to_string(),
1308 "nodes": g.node_count().ok(),
1309 "edges": g.edge_count().ok()
1310 }),
1311 Err(_) => json!({
1312 "exists": true,
1313 "db_path": db_path_display,
1314 "nodes": null,
1315 "edges": null
1316 }),
1317 }
1318}
1319
1320fn git_dirty(project_root: &Path) -> bool {
1321 let out = std::process::Command::new("git")
1322 .args(["status", "--porcelain"])
1323 .current_dir(project_root)
1324 .stdout(std::process::Stdio::piped())
1325 .stderr(std::process::Stdio::null())
1326 .output();
1327 match out {
1328 Ok(o) if o.status.success() => !o.stdout.is_empty(),
1329 _ => false,
1330 }
1331}
1332
1333fn git_out(project_root: &Path, args: &[&str]) -> Option<String> {
1334 let out = std::process::Command::new("git")
1335 .args(args)
1336 .current_dir(project_root)
1337 .stdout(std::process::Stdio::piped())
1338 .stderr(std::process::Stdio::null())
1339 .output()
1340 .ok()?;
1341 if !out.status.success() {
1342 return None;
1343 }
1344 let s = String::from_utf8(out.stdout).ok()?;
1345 let s = s.trim().to_string();
1346 if s.is_empty() {
1347 None
1348 } else {
1349 Some(s)
1350 }
1351}
1352
1353#[cfg(test)]
1354mod tests {
1355 use super::*;
1356
1357 #[test]
1358 fn common_prefix_single() {
1359 let files = vec!["src/core/cache.rs".to_string()];
1360 assert_eq!(common_prefix(&files), "src/core");
1361 }
1362
1363 #[test]
1364 fn common_prefix_multiple() {
1365 let files = vec![
1366 "src/core/cache.rs".to_string(),
1367 "src/core/config.rs".to_string(),
1368 "src/core/session.rs".to_string(),
1369 ];
1370 assert_eq!(common_prefix(&files), "src/core");
1371 }
1372
1373 #[test]
1374 fn common_prefix_different_dirs() {
1375 let files = vec![
1376 "src/tools/ctx_read.rs".to_string(),
1377 "src/core/cache.rs".to_string(),
1378 ];
1379 assert_eq!(common_prefix(&files), "src");
1380 }
1381
1382 #[test]
1383 fn entrypoints_no_dependents() {
1384 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
1385 forward.insert("main.rs".to_string(), vec!["lib.rs".to_string()]);
1386
1387 let all_files: HashSet<String> = ["main.rs", "lib.rs"]
1388 .iter()
1389 .map(std::string::ToString::to_string)
1390 .collect();
1391
1392 let data = GraphData {
1393 forward,
1394 reverse: {
1395 let mut r = HashMap::new();
1396 r.insert("lib.rs".to_string(), vec!["main.rs".to_string()]);
1397 r
1398 },
1399 all_files,
1400 };
1401
1402 let eps = find_entrypoints(&data);
1403 assert_eq!(eps, vec!["main.rs"]);
1404 }
1405
1406 #[test]
1407 fn layers_simple_chain() {
1408 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
1409 forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
1410 forward.insert("b.rs".to_string(), vec!["c.rs".to_string()]);
1411
1412 let mut reverse: HashMap<String, Vec<String>> = HashMap::new();
1413 reverse.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
1414 reverse.insert("c.rs".to_string(), vec!["b.rs".to_string()]);
1415
1416 let all_files: HashSet<String> = ["a.rs", "b.rs", "c.rs"]
1417 .iter()
1418 .map(std::string::ToString::to_string)
1419 .collect();
1420
1421 let data = GraphData {
1422 forward,
1423 reverse,
1424 all_files,
1425 };
1426
1427 let layers = compute_layers(&data);
1428 assert!(layers.len() >= 2);
1429
1430 let layer0 = layers.iter().find(|l| l.depth == 0).unwrap();
1431 assert!(layer0.files.contains(&"c.rs".to_string()));
1432
1433 let layer2 = layers.iter().find(|l| l.depth == 2).unwrap();
1434 assert!(layer2.files.contains(&"a.rs".to_string()));
1435 }
1436
1437 #[test]
1438 fn cycles_detection() {
1439 let mut forward: HashMap<String, Vec<String>> = HashMap::new();
1440 forward.insert("a.rs".to_string(), vec!["b.rs".to_string()]);
1441 forward.insert("b.rs".to_string(), vec!["a.rs".to_string()]);
1442
1443 let all_files: HashSet<String> = ["a.rs", "b.rs"]
1444 .iter()
1445 .map(std::string::ToString::to_string)
1446 .collect();
1447
1448 let data = GraphData {
1449 forward,
1450 reverse: HashMap::new(),
1451 all_files,
1452 };
1453
1454 let cycles = find_cycles(&data);
1455 assert!(!cycles.is_empty());
1456 }
1457
1458 #[test]
1459 fn handle_unknown() {
1460 let result = handle("invalid", None, "/tmp", None);
1461 assert!(result.contains("Unknown action"));
1462 }
1463}