use anyhow::{bail, Result};
use clap::{Args, Subcommand};
use serde::Serialize;
use std::path::Path;
use std::time::Instant;
use crate::config::workspace::WorkspaceConfig;
use crate::config::ProjectConfig;
use crate::core::graph::Graph;
use crate::core::indexer::Indexer;
use crate::core::searcher::Searcher;
use crate::output::formatter;
use crate::output::json::JsonOutput;
#[derive(Args, Debug)]
pub struct WorkspaceArgs {
#[command(subcommand)]
pub command: WorkspaceCommands,
}
#[derive(Subcommand, Debug)]
pub enum WorkspaceCommands {
Init(WorkspaceInitArgs),
List(WorkspaceListArgs),
Index(WorkspaceIndexArgs),
}
#[derive(Args, Debug)]
pub struct WorkspaceInitArgs {
#[arg(long)]
pub name: Option<String>,
}
#[derive(Args, Debug)]
pub struct WorkspaceListArgs {
#[arg(long, short = 'j')]
pub json: bool,
}
#[derive(Args, Debug)]
pub struct WorkspaceIndexArgs {
#[arg(long)]
pub full: bool,
#[arg(long)]
pub watch: bool,
#[arg(long, short = 'j')]
pub json: bool,
}
#[derive(Debug, Serialize)]
pub struct MemberStatus {
pub name: String,
pub path: String,
pub status: String,
pub file_count: usize,
pub symbol_count: usize,
pub last_indexed_at: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceListData {
pub workspace_name: String,
pub members: Vec<MemberStatus>,
}
#[derive(Debug, Serialize)]
pub struct MemberIndexResult {
pub name: String,
pub path: String,
pub status: String,
pub mode: String,
pub symbol_count: usize,
pub edge_count: usize,
pub duration_secs: f64,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceIndexData {
pub workspace_name: String,
pub members: Vec<MemberIndexResult>,
pub total_symbols: usize,
pub total_edges: usize,
pub total_duration_secs: f64,
}
pub fn run(args: &WorkspaceArgs, project_root: &Path) -> Result<()> {
match &args.command {
WorkspaceCommands::Init(init_args) => run_init(init_args, project_root),
WorkspaceCommands::List(list_args) => run_list(list_args, project_root),
WorkspaceCommands::Index(index_args) => run_index(index_args, project_root),
}
}
fn run_init(args: &WorkspaceInitArgs, project_root: &Path) -> Result<()> {
let manifest_path = project_root.join("scope-workspace.toml");
if manifest_path.exists() {
bail!("Workspace already initialized. Edit scope-workspace.toml directly.");
}
let mut members: Vec<(String, String)> = Vec::new();
discover_projects(project_root, project_root, 0, 3, &mut members)?;
if members.is_empty() {
bail!(
"No Scope projects found in subdirectories.\n\
Run 'scope init' in each project directory first, then retry."
);
}
members.sort_by(|a, b| a.0.cmp(&b.0));
let ws_name = args.name.clone().unwrap_or_else(|| {
project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string()
});
let toml_content = WorkspaceConfig::generate_toml(&ws_name, &members);
std::fs::write(&manifest_path, toml_content)?;
let member_names: Vec<&str> = members.iter().map(|(_, name)| name.as_str()).collect();
eprintln!(
"Found {} projects: {}",
members.len(),
member_names.join(", ")
);
eprintln!("Created scope-workspace.toml");
Ok(())
}
fn discover_projects(
base_root: &Path,
current: &Path,
depth: usize,
max_depth: usize,
members: &mut Vec<(String, String)>,
) -> Result<()> {
if depth > max_depth {
return Ok(());
}
let entries = match std::fs::read_dir(current) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!(
"Cannot read directory {}: {e}. Skipping.",
current.display()
);
return Ok(());
}
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
if dir_name.starts_with('.')
|| dir_name == "node_modules"
|| dir_name == "target"
|| dir_name == "dist"
|| dir_name == "build"
{
continue;
}
let scope_config = path.join(".scope").join("config.toml");
if scope_config.exists() {
let rel_path = path
.strip_prefix(base_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let name = dir_name;
members.push((rel_path, name));
continue;
}
discover_projects(base_root, &path, depth + 1, max_depth, members)?;
}
Ok(())
}
fn run_list(args: &WorkspaceListArgs, project_root: &Path) -> Result<()> {
let manifest_path = find_workspace_manifest(project_root)?;
let workspace_root = manifest_path.parent().unwrap_or(project_root);
let config = WorkspaceConfig::load(&manifest_path)?;
let mut member_statuses: Vec<MemberStatus> = Vec::new();
for entry in &config.workspace.members {
let name = WorkspaceConfig::resolve_member_name(entry);
let member_path = workspace_root.join(&entry.path);
let scope_dir = member_path.join(".scope");
let db_path = scope_dir.join("graph.db");
let status = if !scope_dir.exists() {
MemberStatus {
name,
path: entry.path.clone(),
status: "not initialised".to_string(),
file_count: 0,
symbol_count: 0,
last_indexed_at: None,
}
} else if !db_path.exists() {
MemberStatus {
name,
path: entry.path.clone(),
status: "not indexed".to_string(),
file_count: 0,
symbol_count: 0,
last_indexed_at: None,
}
} else {
match Graph::open(&db_path) {
Ok(graph) => {
let symbol_count = graph.symbol_count().unwrap_or(0);
let file_count = graph.file_count().unwrap_or(0);
let last_indexed_at = graph.last_indexed_at().unwrap_or(None);
MemberStatus {
name,
path: entry.path.clone(),
status: "indexed".to_string(),
file_count,
symbol_count,
last_indexed_at,
}
}
Err(e) => {
tracing::warn!("Failed to open graph for member '{}': {e}", name);
MemberStatus {
name,
path: entry.path.clone(),
status: format!("error: {e}"),
file_count: 0,
symbol_count: 0,
last_indexed_at: None,
}
}
}
};
member_statuses.push(status);
}
if args.json {
let data = WorkspaceListData {
workspace_name: config.workspace.name.clone(),
members: member_statuses,
};
let output = JsonOutput {
command: "workspace list",
symbol: None,
data,
truncated: false,
total: config.workspace.members.len(),
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
formatter::print_workspace_list(&config.workspace.name, &member_statuses);
}
Ok(())
}
fn run_index(args: &WorkspaceIndexArgs, project_root: &Path) -> Result<()> {
if args.watch {
return run_workspace_watch(project_root);
}
let manifest_path = find_workspace_manifest(project_root)?;
let workspace_root = manifest_path.parent().unwrap_or(project_root);
let config = WorkspaceConfig::load(&manifest_path)?;
let total_members = config.workspace.members.len();
let overall_start = Instant::now();
let mut results: Vec<MemberIndexResult> = Vec::new();
let mut indexed_count: usize = 0;
let mut total_symbols: usize = 0;
let mut total_edges: usize = 0;
for entry in &config.workspace.members {
let name = WorkspaceConfig::resolve_member_name(entry);
let member_path = workspace_root.join(&entry.path);
let scope_dir = member_path.join(".scope");
let config_path = scope_dir.join("config.toml");
if !config_path.exists() {
eprintln!("[{}] Skipped: no .scope/config.toml found", name);
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "skipped".to_string(),
mode: "skipped".to_string(),
symbol_count: 0,
edge_count: 0,
duration_secs: 0.0,
});
continue;
}
let member_start = Instant::now();
let project_config = match ProjectConfig::load(&scope_dir) {
Ok(c) => c,
Err(e) => {
eprintln!("[{}] Error loading config: {}", name, e);
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "error".to_string(),
mode: if args.full { "full" } else { "incremental" }.to_string(),
symbol_count: 0,
edge_count: 0,
duration_secs: member_start.elapsed().as_secs_f64(),
});
continue;
}
};
let db_path = scope_dir.join("graph.db");
let mut graph = match Graph::open(&db_path) {
Ok(g) => g,
Err(e) => {
eprintln!("[{}] Error opening graph: {}", name, e);
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "error".to_string(),
mode: if args.full { "full" } else { "incremental" }.to_string(),
symbol_count: 0,
edge_count: 0,
duration_secs: member_start.elapsed().as_secs_f64(),
});
continue;
}
};
let mut indexer = match Indexer::new() {
Ok(i) => i,
Err(e) => {
eprintln!("[{}] Error creating indexer: {}", name, e);
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "error".to_string(),
mode: if args.full { "full" } else { "incremental" }.to_string(),
symbol_count: 0,
edge_count: 0,
duration_secs: member_start.elapsed().as_secs_f64(),
});
continue;
}
};
let searcher = match Searcher::open(&db_path) {
Ok(s) => Some(s),
Err(e) => {
tracing::warn!("[{}] Search index unavailable: {e}", name);
None
}
};
let mode = if args.full { "full" } else { "incremental" };
let (symbol_count, edge_count) = if args.full {
match indexer.index_full(&member_path, &project_config, &mut graph, searcher.as_ref()) {
Ok(stats) => (stats.symbol_count, stats.edge_count),
Err(e) => {
eprintln!("[{}] Error during indexing: {}", name, e);
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "error".to_string(),
mode: mode.to_string(),
symbol_count: 0,
edge_count: 0,
duration_secs: member_start.elapsed().as_secs_f64(),
});
continue;
}
}
} else {
match indexer.index_incremental(
&member_path,
&project_config,
&mut graph,
searcher.as_ref(),
) {
Ok(stats) => (stats.symbol_count, stats.edge_count),
Err(e) => {
eprintln!("[{}] Error during indexing: {}", name, e);
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "error".to_string(),
mode: mode.to_string(),
symbol_count: 0,
edge_count: 0,
duration_secs: member_start.elapsed().as_secs_f64(),
});
continue;
}
}
};
let duration = member_start.elapsed();
total_symbols += symbol_count;
total_edges += edge_count;
indexed_count += 1;
if !args.json {
eprintln!(
"[{}] Indexed: {} symbols, {} edges ({:.1}s)",
name,
symbol_count,
edge_count,
duration.as_secs_f64()
);
}
results.push(MemberIndexResult {
name,
path: entry.path.clone(),
status: "indexed".to_string(),
mode: mode.to_string(),
symbol_count,
edge_count,
duration_secs: duration.as_secs_f64(),
});
}
let total_duration = overall_start.elapsed();
if args.json {
let data = WorkspaceIndexData {
workspace_name: config.workspace.name.clone(),
members: results,
total_symbols,
total_edges,
total_duration_secs: total_duration.as_secs_f64(),
};
let output = JsonOutput {
command: "workspace index",
symbol: None,
data,
truncated: false,
total: total_members,
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
eprintln!(
"Workspace indexed: {}/{} members, {} total symbols",
indexed_count, total_members, total_symbols
);
}
Ok(())
}
pub fn find_workspace_manifest(start: &Path) -> Result<std::path::PathBuf> {
let mut current = start.to_path_buf();
loop {
let candidate = current.join("scope-workspace.toml");
if candidate.exists() {
return Ok(candidate);
}
if !current.pop() {
break;
}
}
bail!(
"No scope-workspace.toml found.\n\
Run 'scope workspace init' to create one."
);
}
fn run_workspace_watch(project_root: &Path) -> Result<()> {
let manifest_path = find_workspace_manifest(project_root)?;
let workspace_root = manifest_path.parent().unwrap_or(project_root);
let config = WorkspaceConfig::load(&manifest_path)?;
if config.workspace.members.is_empty() {
bail!("Workspace has no members. Add projects to scope-workspace.toml.");
}
let scope_bin = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("scope"));
let mut children: Vec<(String, std::process::Child)> = Vec::new();
for entry in &config.workspace.members {
let name = WorkspaceConfig::resolve_member_name(entry);
let member_path = workspace_root.join(&entry.path);
let scope_dir = member_path.join(".scope");
if !scope_dir.join("config.toml").exists() {
eprintln!("[{name}] Skipped: no .scope/config.toml");
continue;
}
match std::process::Command::new(&scope_bin)
.args(["index", "--watch"])
.current_dir(&member_path)
.stderr(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
{
Ok(child) => {
eprintln!("[{name}] Watcher started (PID {})", child.id());
children.push((name, child));
}
Err(e) => {
eprintln!("[{name}] Failed to start watcher: {e}");
}
}
}
if children.is_empty() {
bail!("No watchers started. Ensure workspace members are initialised.");
}
eprintln!(
"\nWatching {} member{} (Ctrl+C to stop all)...",
children.len(),
if children.len() == 1 { "" } else { "s" }
);
let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
let running_ctrlc = running.clone();
ctrlc::set_handler(move || {
running_ctrlc.store(false, std::sync::atomic::Ordering::SeqCst);
})
.map_err(|e| anyhow::anyhow!("Failed to set Ctrl+C handler: {e}"))?;
while running.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(500));
for (name, child) in &mut children {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
eprintln!("[{name}] Watcher exited with {status}");
}
}
Ok(None) => {} Err(e) => {
eprintln!("[{name}] Failed to check watcher status: {e}");
}
}
}
}
eprintln!("\nShutting down watchers...");
for (name, mut child) in children {
match child.kill() {
Ok(()) => {
let _ = child.wait();
eprintln!("[{name}] Stopped");
}
Err(e) => {
if e.kind() != std::io::ErrorKind::InvalidInput {
eprintln!("[{name}] Failed to stop: {e}");
}
let _ = child.wait();
}
}
}
eprintln!("All watchers stopped.");
Ok(())
}