use std::path::PathBuf;
use anyhow::Result;
use serde::Serialize;
use crate::core::graph::{Graph, Reference, Symbol};
pub struct WorkspaceGraph {
members: Vec<WorkspaceMember>,
}
pub struct WorkspaceMember {
pub name: String,
#[allow(dead_code)]
pub root: PathBuf,
pub graph: Graph,
}
#[derive(Debug, Clone, Serialize)]
#[allow(dead_code)] pub struct WorkspaceSymbol {
pub project: String,
pub id: String,
pub name: String,
pub kind: String,
pub file_path: String,
pub line_start: u32,
pub line_end: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkspaceRef {
pub project: String,
#[serde(flatten)]
pub reference: Reference,
}
impl WorkspaceGraph {
pub fn open(members: Vec<(String, PathBuf)>) -> Result<Self> {
let total_requested = members.len();
if total_requested > 20 {
tracing::warn!(
"Workspace has {} members. Consider using --project <name> to target a specific member.",
total_requested
);
}
let mut opened: Vec<WorkspaceMember> = Vec::new();
for (name, root) in members {
let db_path = root.join(".scope").join("graph.db");
if !db_path.exists() {
tracing::warn!(
"Member '{}' has no graph.db at {}. Skipping (run 'scope index' in that project).",
name,
db_path.display()
);
continue;
}
match Graph::open(&db_path) {
Ok(graph) => {
opened.push(WorkspaceMember { name, root, graph });
}
Err(e) => {
tracing::warn!(
"Failed to open graph for member '{}': {}. Skipping.",
name,
e
);
}
}
}
let skipped = total_requested.saturating_sub(opened.len());
if skipped > 0 {
eprintln!(
"Warning: {} of {} workspace members not indexed (run 'scope workspace index')",
skipped, total_requested
);
}
Ok(Self { members: opened })
}
#[cfg(test)]
pub fn member_count(&self) -> usize {
self.members.len()
}
#[allow(dead_code)] pub fn member_names(&self) -> Vec<&str> {
self.members.iter().map(|m| m.name.as_str()).collect()
}
pub fn members(&self) -> &[WorkspaceMember] {
&self.members
}
pub fn get_languages(&self) -> Vec<String> {
let mut langs: Vec<String> = Vec::new();
for member in &self.members {
match member.graph.get_languages() {
Ok(member_langs) => {
for lang in member_langs {
if !langs.contains(&lang) {
langs.push(lang);
}
}
}
Err(e) => {
tracing::warn!("Failed to get languages for member '{}': {e}", member.name);
}
}
}
langs.sort();
langs
}
#[allow(dead_code)] pub fn find_symbol(&self, name: &str) -> Vec<WorkspaceSymbol> {
let mut results = Vec::new();
for member in &self.members {
match member.graph.find_symbol(name) {
Ok(Some(sym)) => {
results.push(WorkspaceSymbol {
project: member.name.clone(),
id: sym.id,
name: sym.name,
kind: sym.kind,
file_path: sym.file_path,
line_start: sym.line_start,
line_end: sym.line_end,
});
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
"Error querying member '{}' for symbol '{}': {}",
member.name,
name,
e
);
}
}
}
results
}
pub fn find_refs(
&self,
symbol_name: &str,
kinds: Option<&[&str]>,
limit: usize,
) -> Vec<WorkspaceRef> {
let per_member_limit = limit.saturating_mul(2).max(limit);
let mut all_refs: Vec<WorkspaceRef> = Vec::new();
for member in &self.members {
match member.graph.find_refs(symbol_name, kinds, per_member_limit) {
Ok((refs, _total)) => {
for r in refs {
all_refs.push(WorkspaceRef {
project: member.name.clone(),
reference: r,
});
}
}
Err(e) => {
tracing::warn!(
"Error querying refs in member '{}': {e}. Results may be incomplete.",
member.name
);
}
}
}
all_refs.sort_by(|a, b| {
a.reference
.kind
.cmp(&b.reference.kind)
.then_with(|| a.reference.from_name.cmp(&b.reference.from_name))
});
all_refs.truncate(limit);
all_refs
}
#[allow(dead_code)] pub fn get_entrypoints(&self) -> Vec<(String, Vec<(Symbol, usize)>)> {
let mut results = Vec::new();
for member in &self.members {
match member.graph.get_entrypoints() {
Ok(entries) => {
if !entries.is_empty() {
results.push((member.name.clone(), entries));
}
}
Err(e) => {
tracing::warn!(
"Error getting entrypoints from member '{}': {}",
member.name,
e
);
}
}
}
results
}
pub fn symbol_count(&self) -> usize {
self.members
.iter()
.map(|m| m.graph.symbol_count().unwrap_or(0))
.sum()
}
pub fn edge_count(&self) -> usize {
self.members
.iter()
.map(|m| m.graph.edge_count().unwrap_or(0))
.sum()
}
pub fn file_count(&self) -> usize {
self.members
.iter()
.map(|m| m.graph.file_count().unwrap_or(0))
.sum()
}
}
#[cfg(test)]
pub fn workspace_display_id(project: &str, id: &str) -> String {
format!("{project}::{id}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::graph::Symbol;
use tempfile::TempDir;
fn test_symbol(name: &str, kind: &str, file_path: &str) -> Symbol {
Symbol {
id: format!("{file_path}::{name}::{kind}"),
name: name.to_string(),
kind: kind.to_string(),
file_path: file_path.to_string(),
line_start: 1,
line_end: 50,
signature: None,
docstring: None,
parent_id: None,
language: "typescript".to_string(),
metadata: "{}".to_string(),
}
}
fn create_member_with_db(dir: &TempDir, name: &str) -> PathBuf {
let member_root = dir.path().join(name);
let scope_dir = member_root.join(".scope");
std::fs::create_dir_all(&scope_dir).unwrap();
let db_path = scope_dir.join("graph.db");
let mut graph = Graph::open(&db_path).unwrap();
let sym = test_symbol(&format!("{name}Service"), "class", "src/main.ts");
graph.insert_file_data("src/main.ts", &[sym], &[]).unwrap();
member_root
}
#[test]
fn open_skips_members_without_graph_db() {
let dir = TempDir::new().unwrap();
let api_root = create_member_with_db(&dir, "api");
let worker_root = dir.path().join("worker");
std::fs::create_dir_all(&worker_root).unwrap();
let wg = WorkspaceGraph::open(vec![
("api".to_string(), api_root),
("worker".to_string(), worker_root),
])
.unwrap();
assert_eq!(wg.member_count(), 1);
assert_eq!(wg.member_names(), vec!["api"]);
}
#[test]
fn find_symbol_returns_results_from_multiple_members() {
let dir = TempDir::new().unwrap();
let api_root = create_member_with_db(&dir, "api");
let worker_root = create_member_with_db(&dir, "worker");
let wg = WorkspaceGraph::open(vec![
("api".to_string(), api_root),
("worker".to_string(), worker_root),
])
.unwrap();
let api_results = wg.find_symbol("apiService");
let worker_results = wg.find_symbol("workerService");
assert_eq!(api_results.len(), 1);
assert_eq!(api_results[0].project, "api");
assert_eq!(worker_results.len(), 1);
assert_eq!(worker_results[0].project, "worker");
}
#[test]
fn find_symbol_returns_empty_for_unknown() {
let dir = TempDir::new().unwrap();
let api_root = create_member_with_db(&dir, "api");
let wg = WorkspaceGraph::open(vec![("api".to_string(), api_root)]).unwrap();
let results = wg.find_symbol("NonExistent");
assert!(results.is_empty());
}
#[test]
fn symbol_count_sums_across_members() {
let dir = TempDir::new().unwrap();
let api_root = create_member_with_db(&dir, "api");
let worker_root = create_member_with_db(&dir, "worker");
let wg = WorkspaceGraph::open(vec![
("api".to_string(), api_root),
("worker".to_string(), worker_root),
])
.unwrap();
assert_eq!(wg.symbol_count(), 2);
}
#[test]
fn workspace_display_id_prefixes_correctly() {
let display = workspace_display_id("api", "src/main.ts::PaymentService::class");
assert_eq!(display, "api::src/main.ts::PaymentService::class");
}
#[test]
fn empty_workspace_has_zero_counts() {
let wg = WorkspaceGraph::open(vec![]).unwrap();
assert_eq!(wg.member_count(), 0);
assert_eq!(wg.symbol_count(), 0);
assert_eq!(wg.edge_count(), 0);
assert_eq!(wg.file_count(), 0);
}
}