use crate::deps_graph;
use crate::discovery;
use crate::drift;
use crate::manifest::Manifest;
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RepositoryConfig {
pub name: String,
pub path: PathBuf,
#[serde(default = "default_manifest_path")]
pub manifest: PathBuf,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_manifest_path() -> PathBuf {
PathBuf::from("services.yaml")
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WorkspaceConfig {
pub repos: Vec<RepositoryConfig>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepositoryAnalysis {
pub name: String,
pub path: PathBuf,
pub drift: drift::DriftReport,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceDriftReport {
pub repos: Vec<RepositoryAnalysis>,
pub total_declared: usize,
pub total_discovered: usize,
pub total_errors: usize,
pub total_warnings: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub dependency_summary: Option<deps_graph::DependencySummary>,
#[serde(default)]
pub circular_dependencies: Vec<deps_graph::CircularDependency>,
#[serde(default)]
pub unresolvable_dependencies: Vec<deps_graph::UnresolvableDependency>,
}
impl WorkspaceDriftReport {
pub fn has_errors(&self) -> bool {
self.total_errors > 0
}
pub fn has_warnings(&self) -> bool {
self.total_warnings > 0
}
}
pub struct WorkspaceContext {
pub config: WorkspaceConfig,
pub workspace_root: PathBuf,
pub analyses: Vec<RepositoryAnalysis>,
}
pub fn load_workspace_config(config_path: &Path) -> Result<(WorkspaceConfig, PathBuf)> {
let workspace_root = config_path
.parent()
.ok_or_else(|| anyhow!("cannot determine workspace root from config path"))?
.to_path_buf();
let content = std::fs::read_to_string(config_path).map_err(|e| {
anyhow!(
"cannot read workspace config {}: {}",
config_path.display(),
e
)
})?;
let toml: toml::Value =
toml::from_str(&content).map_err(|e| anyhow!("cannot parse workspace config: {}", e))?;
let workspace = toml
.get("workspace")
.ok_or_else(|| anyhow!("no [workspace] section in config"))?;
let repos: Vec<RepositoryConfig> = workspace
.get("repos")
.and_then(|r| r.as_array())
.ok_or_else(|| anyhow!("workspace.repos must be an array of tables"))?
.iter()
.enumerate()
.map(|(idx, repo)| {
let repo_table = repo
.as_table()
.ok_or_else(|| anyhow!("workspace.repos[{}] must be a table", idx))?;
let name = repo_table
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("workspace.repos[{}].name is required", idx))?
.to_string();
let path = repo_table
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("workspace.repos[{}].path is required", idx))
.map(PathBuf::from)?;
let manifest = repo_table
.get("manifest")
.and_then(|v| v.as_str())
.map(PathBuf::from)
.unwrap_or_else(default_manifest_path);
let enabled = repo_table
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or_else(default_enabled);
Ok(RepositoryConfig {
name,
path,
manifest,
enabled,
})
})
.collect::<Result<Vec<_>>>()?;
if repos.is_empty() {
return Err(anyhow!("workspace must have at least one repository"));
}
Ok((WorkspaceConfig { repos }, workspace_root))
}
fn analyze_repository(
repo_config: &RepositoryConfig,
workspace_root: &Path,
extra_ignore: &[String],
depth: u32,
) -> Result<RepositoryAnalysis> {
let repo_root = if repo_config.path.is_absolute() {
repo_config.path.clone()
} else {
workspace_root.join(&repo_config.path)
};
if !repo_root.exists() {
return Err(anyhow!(
"repository '{}' path does not exist: {}",
repo_config.name,
repo_root.display()
));
}
let manifest_path = repo_root.join(&repo_config.manifest);
let manifest = Manifest::load(&manifest_path).map_err(|e| {
anyhow!(
"failed to load manifest for repository '{}': {}",
repo_config.name,
e
)
})?;
let discovered =
discovery::discover_services_with_opts(&repo_root, &manifest, extra_ignore, depth);
let mut drift_report = drift::analyze(&manifest, &discovered, &repo_root);
drift_report.manifest = manifest_path.display().to_string();
Ok(RepositoryAnalysis {
name: repo_config.name.clone(),
path: repo_root,
drift: drift_report,
})
}
pub fn analyze_workspace(
config: &WorkspaceConfig,
workspace_root: &Path,
extra_ignore: &[String],
depth: u32,
) -> Result<WorkspaceDriftReport> {
let mut analyses = Vec::new();
let mut total_declared = 0;
let mut total_discovered = 0;
let mut total_errors = 0;
let mut total_warnings = 0;
let mut manifests = Vec::new();
for repo_config in &config.repos {
if !repo_config.enabled {
eprintln!("⏭️ Skipping disabled repository: {}", repo_config.name);
continue;
}
match analyze_repository(repo_config, workspace_root, extra_ignore, depth) {
Ok(analysis) => {
total_declared += analysis.drift.declared;
total_discovered += analysis.drift.discovered;
total_errors += analysis.drift.error_count();
total_warnings += analysis.drift.warning_count();
let repo_root = workspace_root.join(&repo_config.path);
let manifest_path = repo_root.join(&repo_config.manifest);
if let Ok(manifest) = Manifest::load(&manifest_path) {
manifests.push((repo_config.name.clone(), manifest));
}
analyses.push(analysis);
}
Err(e) => {
eprintln!(
"❌ Error analyzing repository '{}': {}",
repo_config.name, e
);
return Err(e);
}
}
}
let mut dependency_summary = None;
let mut circular_dependencies = Vec::new();
let mut unresolvable_dependencies = Vec::new();
if !manifests.is_empty() {
let manifest_refs: Vec<(String, &Manifest)> = manifests
.iter()
.map(|(name, manifest)| (name.clone(), manifest))
.collect();
match deps_graph::DependencyGraph::build(manifest_refs) {
Ok(graph) => {
dependency_summary = Some(graph.summary());
circular_dependencies = graph.circular_dependencies.clone();
unresolvable_dependencies = graph.validate_all_dependencies();
}
Err(e) => {
eprintln!("⚠️ Warning: Failed to analyze dependencies: {}", e);
}
}
}
Ok(WorkspaceDriftReport {
repos: analyses,
total_declared,
total_discovered,
total_errors,
total_warnings,
dependency_summary,
circular_dependencies,
unresolvable_dependencies,
})
}
pub fn find_workspace_config(root: &Path) -> Option<PathBuf> {
let config_file = root.join("svccat.toml");
if config_file.exists() {
if let Ok(content) = std::fs::read_to_string(&config_file) {
if let Ok(toml) = content.parse::<toml::Value>() {
if toml.get("workspace").is_some() {
return Some(config_file);
}
}
}
}
None
}