use crate::deps_graph;
use crate::discovery;
use crate::drift;
use crate::manifest::Manifest;
use crate::reporting::ReportingConfig;
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 {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
pub repos: Vec<RepositoryConfig>,
#[serde(default)]
pub reporting: ReportingConfig,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepositoryAnalysis {
pub name: String,
pub path: PathBuf,
pub drift: drift::DriftReport,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceDriftReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_name: Option<String>,
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>,
#[serde(default)]
pub dependency_graph_nodes: Vec<deps_graph::GraphNode>,
}
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 name = workspace
.get("name")
.and_then(|v| v.as_str())
.map(String::from);
let description = workspace
.get("description")
.and_then(|v| v.as_str())
.map(String::from);
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"));
}
let reporting = crate::reporting::parse(toml.get("reporting"))?;
Ok((
WorkspaceConfig {
name,
description,
repos,
reporting,
},
workspace_root,
))
}
pub fn filter_repos(config: &WorkspaceConfig, filter: &str) -> Result<WorkspaceConfig> {
let requested: Vec<&str> = filter
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if requested.is_empty() {
return Err(anyhow!(
"--filter must name at least one repository (comma-separated names)"
));
}
let unknown: Vec<&str> = requested
.iter()
.copied()
.filter(|name| !config.repos.iter().any(|r| r.name == *name))
.collect();
if !unknown.is_empty() {
let available: Vec<&str> = config.repos.iter().map(|r| r.name.as_str()).collect();
return Err(anyhow!(
"unknown repository name(s) in --filter: {} (available: {})",
unknown.join(", "),
available.join(", ")
));
}
let repos = config
.repos
.iter()
.filter(|r| requested.contains(&r.name.as_str()))
.cloned()
.collect();
Ok(WorkspaceConfig {
name: config.name.clone(),
description: config.description.clone(),
repos,
reporting: config.reporting.clone(),
})
}
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,
})
}
#[derive(Default)]
struct CrossRepoAnalysis {
summary: Option<deps_graph::DependencySummary>,
circular: Vec<deps_graph::CircularDependency>,
unresolvable: Vec<deps_graph::UnresolvableDependency>,
nodes: Vec<deps_graph::GraphNode>,
}
fn analyze_cross_repo_dependencies(
config: &WorkspaceConfig,
workspace_root: &Path,
) -> CrossRepoAnalysis {
let manifests: Vec<(String, Manifest)> = config
.repos
.iter()
.filter(|repo| repo.enabled)
.filter_map(|repo| {
let manifest_path = workspace_root.join(&repo.path).join(&repo.manifest);
Manifest::load(&manifest_path)
.ok()
.map(|manifest| (repo.name.clone(), manifest))
})
.collect();
if manifests.is_empty() {
return CrossRepoAnalysis::default();
}
let manifest_refs: Vec<(String, &Manifest)> = manifests
.iter()
.map(|(name, manifest)| (name.clone(), manifest))
.collect();
match deps_graph::DependencyGraph::build(manifest_refs) {
Ok(graph) => CrossRepoAnalysis {
summary: Some(graph.summary()),
circular: graph.circular_dependencies.clone(),
unresolvable: graph.validate_all_dependencies(),
nodes: graph.nodes.values().cloned().collect(),
},
Err(e) => {
eprintln!("⚠️ Warning: Failed to analyze dependencies: {}", e);
CrossRepoAnalysis::default()
}
}
}
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 effective_ignore = config.reporting.merged_ignore(extra_ignore);
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, &effective_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();
analyses.push(analysis);
}
Err(e) => {
eprintln!(
"❌ Error analyzing repository '{}': {}",
repo_config.name, e
);
return Err(e);
}
}
}
let deps = if config.reporting.include_cross_repo_deps {
analyze_cross_repo_dependencies(config, workspace_root)
} else {
CrossRepoAnalysis::default()
};
Ok(WorkspaceDriftReport {
workspace_name: config.name.clone(),
repos: analyses,
total_declared,
total_discovered,
total_errors,
total_warnings,
dependency_summary: deps.summary,
circular_dependencies: deps.circular,
unresolvable_dependencies: deps.unresolvable,
dependency_graph_nodes: deps.nodes,
})
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::OutputFormat;
use tempfile::TempDir;
fn repo(name: &str) -> RepositoryConfig {
RepositoryConfig {
name: name.to_string(),
path: PathBuf::from(name),
manifest: PathBuf::from("services.yaml"),
enabled: true,
}
}
fn sample_config() -> WorkspaceConfig {
WorkspaceConfig {
name: Some("Platform".to_string()),
description: Some("Multi-service platform".to_string()),
repos: vec![repo("alpha"), repo("beta")],
reporting: ReportingConfig::default(),
}
}
fn write_config(dir: &TempDir, content: &str) -> PathBuf {
let path = dir.path().join("svccat.toml");
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn load_parses_workspace_name_and_description() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[workspace]
name = "Platform Engineering"
description = "Multi-service platform"
repos = [{ name = "api", path = "api-repo" }]
"#,
);
let (config, root) = load_workspace_config(&path).unwrap();
assert_eq!(config.name.as_deref(), Some("Platform Engineering"));
assert_eq!(
config.description.as_deref(),
Some("Multi-service platform")
);
assert_eq!(config.repos.len(), 1);
assert_eq!(root, dir.path());
}
#[test]
fn load_defaults_name_and_description_to_none() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[workspace]
repos = [{ name = "api", path = "api-repo" }]
"#,
);
let (config, _) = load_workspace_config(&path).unwrap();
assert_eq!(config.name, None);
assert_eq!(config.description, None);
}
#[test]
fn filter_selects_single_repo() {
let filtered = filter_repos(&sample_config(), "beta").unwrap();
assert_eq!(filtered.repos.len(), 1);
assert_eq!(filtered.repos[0].name, "beta");
assert_eq!(filtered.name.as_deref(), Some("Platform"));
}
#[test]
fn filter_trims_whitespace_and_keeps_config_order() {
let filtered = filter_repos(&sample_config(), " beta , alpha ").unwrap();
let names: Vec<&str> = filtered.repos.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn filter_ignores_duplicate_names() {
let filtered = filter_repos(&sample_config(), "alpha,alpha").unwrap();
assert_eq!(filtered.repos.len(), 1);
assert_eq!(filtered.repos[0].name, "alpha");
}
#[test]
fn filter_rejects_unknown_repo_names() {
let err = filter_repos(&sample_config(), "alpha,nope").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("nope"),
"message should name the unknown repo: {msg}"
);
assert!(
msg.contains("alpha") && msg.contains("beta"),
"message should list available repos: {msg}"
);
}
#[test]
fn filter_rejects_empty_filter() {
assert!(filter_repos(&sample_config(), "").is_err());
assert!(filter_repos(&sample_config(), " , ").is_err());
}
#[test]
fn load_defaults_reporting_when_section_absent() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[workspace]
repos = [{ name = "api", path = "api-repo" }]
"#,
);
let (config, _) = load_workspace_config(&path).unwrap();
assert_eq!(config.reporting, ReportingConfig::default());
}
#[test]
fn load_wires_reporting_section_into_config() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[workspace]
repos = [{ name = "api", path = "api-repo" }]
[reporting]
format = "json"
include_cross_repo_deps = false
exclude_patterns = ["examples/*", "vendor"]
"#,
);
let (config, _) = load_workspace_config(&path).unwrap();
assert_eq!(config.reporting.format, Some(OutputFormat::Json));
assert!(!config.reporting.include_cross_repo_deps);
assert_eq!(
config.reporting.exclude_patterns,
vec!["examples/*".to_string(), "vendor".to_string()]
);
}
#[test]
fn load_rejects_bad_reporting_value() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[workspace]
repos = [{ name = "api", path = "api-repo" }]
[reporting]
format = "jsonn"
"#,
);
assert!(load_workspace_config(&path).is_err());
}
#[test]
fn filter_repos_preserves_reporting() {
let mut config = sample_config();
config.reporting = ReportingConfig {
format: Some(OutputFormat::Json),
include_cross_repo_deps: false,
exclude_patterns: vec!["vendor".to_string()],
};
let filtered = filter_repos(&config, "alpha").unwrap();
assert_eq!(filtered.reporting, config.reporting);
}
}