use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
pub struct WorkspaceConfig {
pub workspace: WorkspaceSection,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceSection {
pub name: String,
pub version: Option<u32>,
#[serde(default)]
pub defaults: WorkspaceDefaults,
pub members: Vec<WorkspaceMemberEntry>,
}
#[derive(Debug, Default, Deserialize)]
pub struct WorkspaceDefaults {
pub index: Option<IndexDefaults>,
pub output: Option<OutputDefaults>,
}
#[derive(Debug, Deserialize)]
pub struct IndexDefaults {
pub ignore: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct OutputDefaults {
pub max_refs: Option<usize>,
pub max_impact_depth: Option<usize>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceMemberEntry {
pub path: String,
pub name: Option<String>,
}
impl WorkspaceConfig {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let config: WorkspaceConfig =
toml::from_str(&content).with_context(|| "Failed to parse scope-workspace.toml")?;
if let Some(version) = config.workspace.version {
if version > 1 {
bail!(
"Unsupported workspace version {}. This version of Scope supports version 1.",
version
);
}
}
Ok(config)
}
pub fn resolve_member_name(entry: &WorkspaceMemberEntry) -> String {
if let Some(ref name) = entry.name {
return name.clone();
}
Path::new(&entry.path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
pub fn validate(&self, workspace_root: &Path) -> Result<()> {
let canonical_root = workspace_root.canonicalize().with_context(|| {
format!(
"Cannot canonicalize workspace root: {}",
workspace_root.display()
)
})?;
let mut seen_names: HashSet<String> = HashSet::new();
let mut canonical_paths: Vec<(String, PathBuf)> = Vec::new();
for entry in &self.workspace.members {
let name = Self::resolve_member_name(entry);
if !seen_names.insert(name.clone()) {
bail!("Duplicate member name '{}'", name);
}
let member_path = workspace_root.join(&entry.path);
if !member_path.exists() {
bail!("Member path '{}' does not exist", entry.path);
}
let canonical_member = member_path.canonicalize().with_context(|| {
format!("Cannot canonicalize member path: {}", member_path.display())
})?;
if !canonical_member.starts_with(&canonical_root) {
bail!(
"Member '{}' is outside workspace root. \
Set allow_external = true to permit this.",
name
);
}
canonical_paths.push((name, canonical_member));
}
for i in 0..canonical_paths.len() {
for j in (i + 1)..canonical_paths.len() {
let (ref name_a, ref path_a) = canonical_paths[i];
let (ref name_b, ref path_b) = canonical_paths[j];
if path_a.starts_with(path_b) || path_b.starts_with(path_a) {
bail!(
"Member '{}' path overlaps with member '{}'. \
Each member must have a distinct, non-overlapping directory.",
name_a,
name_b
);
}
}
}
Ok(())
}
pub fn generate_toml(name: &str, members: &[(String, String)]) -> String {
let mut toml = String::new();
toml.push_str(
"# scope-workspace.toml — workspace manifest for multi-project Scope queries.\n",
);
toml.push_str("# Place this file at the root of your workspace.\n\n");
toml.push_str("[workspace]\n");
toml.push_str(&format!("name = \"{name}\"\n"));
toml.push_str("version = 1\n\n");
for (member_path, member_name) in members {
toml.push_str("[[workspace.members]]\n");
let normalized = member_path.replace('\\', "/");
toml.push_str(&format!("path = \"{normalized}\"\n"));
toml.push_str(&format!("name = \"{member_name}\"\n\n"));
}
toml
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_workspace(member_dirs: &[&str]) -> TempDir {
let dir = TempDir::new().unwrap();
for member in member_dirs {
let member_path = dir.path().join(member);
std::fs::create_dir_all(&member_path).unwrap();
}
dir
}
#[test]
fn parse_valid_workspace_toml() {
let toml_str = r#"
[workspace]
name = "my-workspace"
version = 1
[[workspace.members]]
path = "services/api"
name = "api"
[[workspace.members]]
path = "services/worker"
"#;
let config: WorkspaceConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.workspace.name, "my-workspace");
assert_eq!(config.workspace.version, Some(1));
assert_eq!(config.workspace.members.len(), 2);
assert_eq!(config.workspace.members[0].path, "services/api");
assert_eq!(config.workspace.members[0].name, Some("api".to_string()));
assert_eq!(config.workspace.members[1].path, "services/worker");
assert_eq!(config.workspace.members[1].name, None);
}
#[test]
fn parse_workspace_with_defaults() {
let toml_str = r#"
[workspace]
name = "test"
[workspace.defaults.index]
ignore = ["node_modules", "dist"]
[workspace.defaults.output]
max_refs = 30
max_impact_depth = 5
[[workspace.members]]
path = "api"
"#;
let config: WorkspaceConfig = toml::from_str(toml_str).unwrap();
let index = config.workspace.defaults.index.unwrap();
assert_eq!(index.ignore.unwrap(), vec!["node_modules", "dist"]);
let output = config.workspace.defaults.output.unwrap();
assert_eq!(output.max_refs, Some(30));
assert_eq!(output.max_impact_depth, Some(5));
}
#[test]
fn member_name_defaults_to_directory_basename() {
let entry = WorkspaceMemberEntry {
path: "services/api".to_string(),
name: None,
};
assert_eq!(WorkspaceConfig::resolve_member_name(&entry), "api");
}
#[test]
fn member_name_uses_explicit_name() {
let entry = WorkspaceMemberEntry {
path: "services/api-v2".to_string(),
name: Some("api".to_string()),
};
assert_eq!(WorkspaceConfig::resolve_member_name(&entry), "api");
}
#[test]
fn validate_rejects_missing_member_path() {
let dir = setup_workspace(&[]);
let toml_str = r#"
[workspace]
name = "test"
[[workspace.members]]
path = "does-not-exist"
"#;
let config: WorkspaceConfig = toml::from_str(toml_str).unwrap();
let err = config.validate(dir.path()).unwrap_err();
assert!(
err.to_string().contains("does not exist"),
"Expected 'does not exist' error, got: {}",
err
);
}
#[test]
fn validate_rejects_duplicate_member_names() {
let dir = setup_workspace(&["a", "b"]);
let toml_str = r#"
[workspace]
name = "test"
[[workspace.members]]
path = "a"
name = "shared"
[[workspace.members]]
path = "b"
name = "shared"
"#;
let config: WorkspaceConfig = toml::from_str(toml_str).unwrap();
let err = config.validate(dir.path()).unwrap_err();
assert!(
err.to_string().contains("Duplicate member name"),
"Expected duplicate name error, got: {}",
err
);
}
#[test]
fn validate_rejects_overlapping_paths() {
let dir = setup_workspace(&["parent", "parent/child"]);
let toml_str = r#"
[workspace]
name = "test"
[[workspace.members]]
path = "parent"
[[workspace.members]]
path = "parent/child"
"#;
let config: WorkspaceConfig = toml::from_str(toml_str).unwrap();
let err = config.validate(dir.path()).unwrap_err();
assert!(
err.to_string().contains("overlaps"),
"Expected overlapping path error, got: {}",
err
);
}
#[test]
fn validate_accepts_valid_workspace() {
let dir = setup_workspace(&["api", "worker", "shared"]);
let toml_str = r#"
[workspace]
name = "test"
[[workspace.members]]
path = "api"
[[workspace.members]]
path = "worker"
[[workspace.members]]
path = "shared"
"#;
let config: WorkspaceConfig = toml::from_str(toml_str).unwrap();
config.validate(dir.path()).unwrap();
}
#[test]
fn load_rejects_unsupported_version() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("scope-workspace.toml");
std::fs::write(
&manifest_path,
r#"
[workspace]
name = "test"
version = 99
[[workspace.members]]
path = "api"
"#,
)
.unwrap();
let err = WorkspaceConfig::load(&manifest_path).unwrap_err();
assert!(
err.to_string().contains("Unsupported workspace version"),
"Expected version error, got: {}",
err
);
}
#[test]
fn generate_toml_produces_valid_manifest() {
let members = vec![
("services/api".to_string(), "api".to_string()),
("libs/shared".to_string(), "shared".to_string()),
];
let toml_str = WorkspaceConfig::generate_toml("my-workspace", &members);
let config: WorkspaceConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.workspace.name, "my-workspace");
assert_eq!(config.workspace.members.len(), 2);
assert_eq!(config.workspace.members[0].path, "services/api");
assert_eq!(config.workspace.members[0].name, Some("api".to_string()));
}
}