use crate::analysis::{self, DependencyGraph, ImpactAnalysis, ImpactError};
use crate::cli::{AiOutputFormat, PriorityStrategy};
use crate::config::{Config, ConfigError};
use crate::fs::{FileSystem, default_fs};
use crate::model::AnalysisResult;
use crate::output::{AiOutput, OutputFormatter};
use crate::parser::ParserRegistry;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ArchmapError {
#[error("Path not found: {0}")]
PathNotFound(PathBuf),
#[error("Configuration error: {0}")]
Config(#[from] ConfigError),
#[error("Impact analysis error: {0}")]
Impact(#[from] ImpactError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct AnalysisOptions {
pub languages: Vec<String>,
pub exclude: Vec<String>,
pub max_depth: usize,
pub min_cohesion: f64,
}
impl Default for AnalysisOptions {
fn default() -> Self {
Self {
languages: Vec::new(),
exclude: Vec::new(),
max_depth: 5,
min_cohesion: 0.3,
}
}
}
#[derive(Debug, Clone)]
pub struct ImpactOptions {
pub languages: Vec<String>,
pub depth: Option<usize>,
}
impl Default for ImpactOptions {
fn default() -> Self {
Self {
languages: Vec::new(),
depth: None,
}
}
}
#[derive(Debug, Clone)]
pub struct AiOptions {
pub languages: Vec<String>,
pub tokens: Option<usize>,
pub signatures_only: bool,
pub topo_order: bool,
pub format: AiFormat,
pub priority: Priority,
}
impl Default for AiOptions {
fn default() -> Self {
Self {
languages: Vec::new(),
tokens: None,
signatures_only: false,
topo_order: true,
format: AiFormat::Markdown,
priority: Priority::FanIn,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum AiFormat {
#[default]
Markdown,
Json,
Xml,
}
impl From<AiFormat> for AiOutputFormat {
fn from(f: AiFormat) -> Self {
match f {
AiFormat::Markdown => AiOutputFormat::Markdown,
AiFormat::Json => AiOutputFormat::Json,
AiFormat::Xml => AiOutputFormat::Xml,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub enum Priority {
#[default]
FanIn,
FanOut,
Combined,
}
impl From<Priority> for PriorityStrategy {
fn from(p: Priority) -> Self {
match p {
Priority::FanIn => PriorityStrategy::FanIn,
Priority::FanOut => PriorityStrategy::FanOut,
Priority::Combined => PriorityStrategy::Combined,
}
}
}
pub struct ImpactResult {
inner: ImpactAnalysis,
project_root: PathBuf,
}
impl ImpactResult {
pub fn target(&self) -> &Path {
&self.inner.target
}
pub fn total_affected(&self) -> usize {
self.inner.total_affected
}
pub fn max_chain_length(&self) -> usize {
self.inner.max_chain_length
}
pub fn affected_by_depth(&self) -> &[Vec<PathBuf>] {
&self.inner.affected_by_depth
}
pub fn all_affected(&self) -> Vec<&Path> {
self.inner
.affected_by_depth
.iter()
.flatten()
.map(|p| p.as_path())
.collect()
}
pub fn to_markdown(&self, show_tree: bool) -> String {
analysis::format_impact_markdown(&self.inner, Some(&self.project_root), show_tree)
}
pub fn to_json(&self) -> String {
analysis::format_impact_json(&self.inner, Some(&self.project_root))
}
pub fn inner(&self) -> &ImpactAnalysis {
&self.inner
}
}
pub fn analyze(path: &Path, options: AnalysisOptions) -> Result<AnalysisResult, ArchmapError> {
let resolved_path = path
.canonicalize()
.map_err(|_| ArchmapError::PathNotFound(path.to_path_buf()))?;
let mut config = Config::load(&resolved_path).unwrap_or_default();
config.thresholds.max_dependency_depth = options.max_depth;
config.thresholds.min_cohesion = options.min_cohesion;
let registry = if options.languages.is_empty() {
ParserRegistry::new()
} else {
ParserRegistry::with_languages(&options.languages)
};
let result = analysis::analyze(&resolved_path, &config, ®istry, &options.exclude);
Ok(result)
}
pub fn impact(
project_path: &Path,
file: &Path,
options: ImpactOptions,
) -> Result<ImpactResult, ArchmapError> {
let resolved_path = project_path
.canonicalize()
.map_err(|_| ArchmapError::PathNotFound(project_path.to_path_buf()))?;
let target_file = if file.is_absolute() {
file.to_path_buf()
} else {
resolved_path.join(file)
};
let target_file = target_file
.canonicalize()
.map_err(|_| ArchmapError::PathNotFound(file.to_path_buf()))?;
let config = Config::load(&resolved_path).unwrap_or_default();
let registry = if options.languages.is_empty() {
ParserRegistry::new()
} else {
ParserRegistry::with_languages(&options.languages)
};
let result = analysis::analyze(&resolved_path, &config, ®istry, &[]);
let graph = DependencyGraph::build(&result.modules);
let impact_analysis = analysis::compute_impact(&graph, &target_file, options.depth)?;
Ok(ImpactResult {
inner: impact_analysis,
project_root: resolved_path,
})
}
pub fn ai_context(path: &Path, options: AiOptions) -> Result<String, ArchmapError> {
let resolved_path = path
.canonicalize()
.map_err(|_| ArchmapError::PathNotFound(path.to_path_buf()))?;
let config = Config::load(&resolved_path).unwrap_or_default();
let registry = if options.languages.is_empty() {
ParserRegistry::new()
} else {
ParserRegistry::with_languages(&options.languages)
};
let sources = collect_sources(&resolved_path, ®istry);
let result = analysis::analyze(&resolved_path, &config, ®istry, &[]);
let mut formatter = AiOutput::new(Some(resolved_path))
.with_topo_order(options.topo_order)
.with_signatures_only(options.signatures_only)
.with_priority(options.priority.into())
.with_format(options.format.into())
.with_sources(sources);
if let Some(tokens) = options.tokens {
formatter = formatter.with_token_budget(tokens);
}
let mut buffer = Cursor::new(Vec::new());
formatter.format(&result, &mut buffer)?;
let output = String::from_utf8_lossy(&buffer.into_inner()).to_string();
Ok(output)
}
fn collect_sources(path: &Path, registry: &ParserRegistry) -> HashMap<PathBuf, String> {
let fs = default_fs();
let mut sources = HashMap::new();
let walker = ignore::WalkBuilder::new(path)
.hidden(true)
.git_ignore(true)
.build();
for entry in walker.flatten() {
let file_path = entry.path();
if file_path.is_file() && registry.find_parser(file_path).is_some() {
if let Ok(content) = fs.read_to_string(file_path) {
sources.insert(file_path.to_path_buf(), content);
}
}
}
sources
}