Candidate 1 (NormalizedGraph): qualify→resolve→filter is now a single
named operation returning a distinct type; raw CodeGraph cannot call
module_edges/subgraph_by_module — pipeline order enforced at compile time.
Candidate 2 (Use cases): GenerateDiagram, CheckFreshness, DiffDiagram
extracted to application/src/use_cases/; presentation is now a thin CLI
dispatcher (~100 lines less, three fewer local functions).
Candidate 3 (ExtractionContext): shared accumulator for both Rust and
Python extractors replaces parallel Vec<> + 4-arg passing chains.
Candidate 4 (ModuleAssignment): ModuleName::assign() returns
ModuleAssignment { Explicit | Inferred | Unresolved } instead of Option,
callers can distinguish resolution strategies.
Candidate 5 (SplitRenderer): append_cross_module_deps removed from
DiagramRenderer port; replaced by render_for_module() default impl —
port interface now reflects what all renderers actually share.
131 lines
4.0 KiB
Rust
131 lines
4.0 KiB
Rust
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
|
|
use crate::DomainError;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct ModuleName(String);
|
|
|
|
/// How a module name was assigned to a source file.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ModuleAssignment {
|
|
/// Matched an explicit user-configured mapping in `archlens.toml`.
|
|
Explicit(ModuleName),
|
|
/// Derived from file path structure by heuristic (e.g. `crates/<name>/...`).
|
|
Inferred(ModuleName),
|
|
/// No mapping matched and the heuristic could not determine a module.
|
|
Unresolved(&'static str),
|
|
}
|
|
|
|
impl ModuleAssignment {
|
|
pub fn module_name(&self) -> Option<&ModuleName> {
|
|
match self {
|
|
Self::Explicit(m) | Self::Inferred(m) => Some(m),
|
|
Self::Unresolved(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn into_module_name(self) -> Option<ModuleName> {
|
|
match self {
|
|
Self::Explicit(m) | Self::Inferred(m) => Some(m),
|
|
Self::Unresolved(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn reason(&self) -> Option<&'static str> {
|
|
match self {
|
|
Self::Unresolved(r) => Some(r),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ModuleName {
|
|
pub fn new(value: &str) -> Result<Self, DomainError> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(DomainError::EmptyValue("ModuleName"));
|
|
}
|
|
Ok(Self(trimmed.to_string()))
|
|
}
|
|
|
|
/// Assign a module to a file path, returning a typed assignment that
|
|
/// distinguishes explicit mappings, heuristic inference, and failure.
|
|
pub fn assign(
|
|
file_path: &str,
|
|
root: &Path,
|
|
module_mappings: &HashMap<String, String>,
|
|
) -> ModuleAssignment {
|
|
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
|
let root_str = canonical_root.to_str().unwrap_or("");
|
|
let relative = file_path
|
|
.strip_prefix(root_str)
|
|
.unwrap_or(file_path)
|
|
.trim_start_matches('/');
|
|
|
|
// 1. Explicit mapping
|
|
for (pattern, module_name) in module_mappings {
|
|
if relative.starts_with(pattern.as_str())
|
|
&& let Ok(m) = Self::new(module_name)
|
|
{
|
|
return ModuleAssignment::Explicit(m);
|
|
}
|
|
}
|
|
|
|
// 2. Heuristic inference from path structure
|
|
let parts: Vec<&str> = relative.split('/').collect();
|
|
if parts.len() <= 1 {
|
|
return ModuleAssignment::Unresolved("path has no directory component");
|
|
}
|
|
|
|
let module_dir = if (parts[0] == "crates" || parts[0] == "src") && parts.len() > 2 {
|
|
parts[1]
|
|
} else if parts[0] != "src" && parts.len() > 1 {
|
|
parts[0]
|
|
} else {
|
|
return ModuleAssignment::Unresolved("path under src/ with no further structure");
|
|
};
|
|
|
|
match Self::new(&Self::capitalize(module_dir)) {
|
|
Ok(m) => ModuleAssignment::Inferred(m),
|
|
Err(_) => ModuleAssignment::Unresolved("inferred directory name was empty"),
|
|
}
|
|
}
|
|
|
|
/// Convenience wrapper — returns None for Unresolved.
|
|
/// Prefer `assign()` when you want to distinguish strategies.
|
|
pub fn from_path(
|
|
file_path: &str,
|
|
root: &Path,
|
|
module_mappings: &HashMap<String, String>,
|
|
) -> Option<Self> {
|
|
Self::assign(file_path, root, module_mappings).into_module_name()
|
|
}
|
|
|
|
pub fn from_directory_group(member_path: &str) -> Option<Self> {
|
|
let parts: Vec<&str> = member_path.split('/').collect();
|
|
if parts.len() < 3 {
|
|
return None;
|
|
}
|
|
let group = parts[parts.len() - 2];
|
|
Self::new(&Self::capitalize(group)).ok()
|
|
}
|
|
|
|
pub fn capitalize(s: &str) -> String {
|
|
s.split('-')
|
|
.map(|seg| {
|
|
if seg.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("{}{}", seg[..1].to_uppercase(), &seg[1..])
|
|
}
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("-")
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|