use std::path::{Path, PathBuf};
pub const ROOT_PROJECT_ID: &str = "root";
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
pub id: String,
pub relative_root: PathBuf,
pub languages: Vec<String>,
pub manifest: Option<String>,
}
fn read_file_capped(path: &Path, max_bytes: u64) -> std::io::Result<String> {
use std::io::Read;
let f = std::fs::File::open(path)?;
let mut buf = String::new();
f.take(max_bytes).read_to_string(&mut buf)?;
Ok(buf)
}
pub fn discover_projects(
workspace_root: &Path,
max_depth: usize,
exclude: &[String],
) -> Vec<DiscoveredProject> {
let manifests: &[(&str, &[&str])] = &[
("Cargo.toml", &["rust"]),
("build.gradle.kts", &["kotlin", "java"]),
("build.gradle", &["kotlin", "java"]),
("go.mod", &["go"]),
("pom.xml", &["java"]),
("CMakeLists.txt", &["c", "cpp"]),
("mix.exs", &["elixir"]),
("Gemfile", &["ruby"]),
];
let conditional_manifests: &[(&str, &[&str])] = &[
("package.json", &["typescript", "javascript"]),
("pyproject.toml", &["python"]),
("setup.py", &["python"]),
("requirements.txt", &["python"]),
];
let mut manifest_dirs: std::collections::BTreeMap<PathBuf, (String, Vec<String>)> =
std::collections::BTreeMap::new();
let walker = ignore::WalkBuilder::new(workspace_root)
.hidden(true)
.git_ignore(true)
.max_depth(Some(max_depth + 1))
.build();
for entry in walker.flatten() {
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let file_name = entry.file_name().to_string_lossy().to_string();
let dir = entry.path().parent().unwrap_or(entry.path()).to_path_buf();
let rel_dir = dir.strip_prefix(workspace_root).unwrap_or(&dir);
if exclude.iter().any(|ex| {
rel_dir
.components()
.any(|c| c.as_os_str().to_string_lossy() == *ex)
}) {
continue;
}
if rel_dir
.components()
.any(|c| c.as_os_str() == "node_modules")
{
continue;
}
for (manifest_name, langs) in manifests {
if file_name == *manifest_name && !manifest_dirs.contains_key(&dir) {
manifest_dirs.insert(
dir.clone(),
(
manifest_name.to_string(),
langs.iter().map(|s| s.to_string()).collect(),
),
);
break;
}
}
for (manifest_name, langs) in conditional_manifests {
if file_name != *manifest_name || manifest_dirs.contains_key(&dir) {
continue;
}
if *manifest_name == "package.json" {
if let Ok(content) = read_file_capped(entry.path(), 1024 * 1024) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
let has_scripts = json
.get("scripts")
.and_then(|v| v.as_object())
.map(|o| !o.is_empty())
.unwrap_or(false);
let has_main = json.get("main").is_some() || json.get("module").is_some();
if !has_scripts && !has_main {
continue;
}
} else {
continue;
}
} else {
continue;
}
}
if *manifest_name == "requirements.txt" && dir.join("pyproject.toml").exists() {
continue;
}
manifest_dirs.insert(
dir.clone(),
(
manifest_name.to_string(),
langs.iter().map(|s| s.to_string()).collect(),
),
);
break;
}
}
let workspace_name = workspace_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed")
.to_string();
let mut dirs: Vec<_> = manifest_dirs.into_iter().collect();
dirs.sort_by_key(|(p, _)| p.components().count());
let mut found: Vec<DiscoveredProject> = Vec::new();
let mut found_roots: Vec<PathBuf> = Vec::new();
for (dir, (manifest, languages)) in dirs {
let rel = dir.strip_prefix(workspace_root).unwrap_or(&dir);
let rel_path = if rel.as_os_str().is_empty() {
PathBuf::from(".")
} else {
rel.to_path_buf()
};
let dominated = found_roots.iter().any(|existing| {
if rel_path == std::path::Path::new(".") || existing == std::path::Path::new(".") {
return false;
}
rel_path.starts_with(existing)
&& found.iter().any(|p| {
p.relative_root == *existing
&& p.languages.iter().any(|l| languages.contains(l))
})
});
if dominated {
continue;
}
let id = if rel_path == std::path::Path::new(".") {
workspace_name.clone()
} else {
rel_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed")
.to_string()
};
let final_id = if found.iter().any(|p| p.id == id) {
rel_path.to_string_lossy().replace('/', "-")
} else {
id
};
found_roots.push(rel_path.clone());
found.push(DiscoveredProject {
id: final_id,
relative_root: rel_path,
languages,
manifest: Some(manifest),
});
}
if let Some(root_idx) = found
.iter()
.position(|p| p.relative_root == std::path::Path::new("."))
{
if root_idx != 0 {
let root = found.remove(root_idx);
found.insert(0, root);
}
}
found
}
fn lexically_normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
if !matches!(
out.components().next_back(),
Some(std::path::Component::Normal(_))
) {
out.push("..");
} else {
out.pop();
}
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn resolve_project_for_path<'a>(
projects: &'a [DiscoveredProject],
workspace_root: &Path,
file_path: &Path,
) -> Option<&'a DiscoveredProject> {
let abs_file = if file_path.is_relative() {
workspace_root.join(file_path)
} else {
file_path.to_path_buf()
};
let abs_file = lexically_normalize(&abs_file);
projects
.iter()
.filter(|p| {
let project_abs = if p.relative_root == std::path::Path::new(".") {
workspace_root.to_path_buf()
} else {
workspace_root.join(&p.relative_root)
};
let project_abs = lexically_normalize(&project_abs);
abs_file.starts_with(&project_abs)
})
.max_by_key(|p| p.relative_root.components().count())
}
pub fn resolve_project_id(
projects: &[DiscoveredProject],
workspace_root: &Path,
file_path: &Path,
) -> String {
resolve_project_for_path(projects, workspace_root, file_path)
.map(|p| p.id.clone())
.unwrap_or_else(|| ROOT_PROJECT_ID.to_string())
}
pub enum ProjectState {
Dormant,
Activated(Box<crate::agent::ActiveProject>),
}
pub struct Project {
pub discovered: DiscoveredProject,
pub state: ProjectState,
}
impl Project {
pub fn new_dormant(discovered: DiscoveredProject) -> Self {
Self {
discovered,
state: ProjectState::Dormant,
}
}
}
pub struct Workspace {
pub root: PathBuf,
pub projects: Vec<Project>,
pub focused: Option<String>,
}
impl Workspace {
pub fn new(root: PathBuf, projects: Vec<Project>) -> Self {
let focused = projects
.iter()
.find(|p| p.discovered.relative_root == std::path::Path::new("."))
.or_else(|| projects.first())
.map(|p| p.discovered.id.clone());
Self {
root,
projects,
focused,
}
}
pub fn focused_project_root(&self) -> anyhow::Result<PathBuf> {
let id = self
.focused
.as_deref()
.ok_or_else(|| anyhow::anyhow!("No focused project"))?;
self.project_root_by_id(id)
}
pub fn project_root_by_id(&self, id: &str) -> anyhow::Result<PathBuf> {
let project = self
.projects
.iter()
.find(|p| p.discovered.id == id)
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found in workspace", id))?;
let abs = if project.discovered.relative_root == std::path::Path::new(".") {
self.root.clone()
} else {
self.root.join(&project.discovered.relative_root)
};
Ok(abs)
}
pub fn resolve_root(
&self,
project: Option<&str>,
file_hint: Option<&Path>,
) -> anyhow::Result<PathBuf> {
match (project, file_hint) {
(Some(id), _) => self.project_root_by_id(id),
(None, Some(path)) => {
let discovered: Vec<_> =
self.projects.iter().map(|p| p.discovered.clone()).collect();
let result = resolve_project_for_path(&discovered, &self.root, path);
match result {
Some(p) => self.project_root_by_id(&p.id),
None => self.focused_project_root(),
}
}
(None, None) => self.focused_project_root(),
}
}
pub fn focused_active(&self) -> Option<&Project> {
let id = self.focused.as_deref()?;
self.projects.iter().find(|p| p.discovered.id == id)
}
pub fn focused_active_mut(&mut self) -> Option<&mut Project> {
let id = self.focused.clone()?;
self.projects.iter_mut().find(|p| p.discovered.id == id)
}
pub fn set_focused(&mut self, project_id: &str) -> anyhow::Result<()> {
if self.projects.iter().any(|p| p.discovered.id == project_id) {
self.focused = Some(project_id.to_string());
Ok(())
} else {
Err(anyhow::anyhow!(
"Project '{}' not found in workspace",
project_id
))
}
}
pub fn project_ids(&self) -> Vec<String> {
self.projects
.iter()
.map(|p| p.discovered.id.clone())
.collect()
}
pub fn memory_dir_for_project(&self, project_id: &str) -> PathBuf {
let is_root = self
.projects
.iter()
.find(|p| p.discovered.id == project_id)
.map(|p| p.discovered.relative_root == std::path::Path::new("."))
.unwrap_or(false);
if is_root {
self.root.join(".codescout").join("memories")
} else {
self.root
.join(".codescout")
.join("projects")
.join(project_id)
.join("memories")
}
}
}
impl Project {
pub fn as_active(&self) -> Option<&crate::agent::ActiveProject> {
match &self.state {
ProjectState::Activated(ap) => Some(ap.as_ref()),
ProjectState::Dormant => None,
}
}
pub fn as_active_mut(&mut self) -> Option<&mut crate::agent::ActiveProject> {
match &mut self.state {
ProjectState::Activated(ap) => Some(ap.as_mut()),
ProjectState::Dormant => None,
}
}
}
fn normalize_path(base: &Path, relative: &str) -> PathBuf {
let mut result = base.to_path_buf();
for component in Path::new(relative).components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::Normal(c) => result.push(c),
std::path::Component::CurDir => {}
_ => result.push(component),
}
}
result
}
fn deps_from_cargo(project_root: &Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(project_root.join("Cargo.toml")) else {
return vec![];
};
let Ok(table) = toml::from_str::<toml::Value>(&content) else {
return vec![];
};
let mut paths = vec![];
for section in &["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = table.get(section).and_then(|v| v.as_table()) {
for dep in deps.values() {
if let Some(p) = dep.get("path").and_then(|v| v.as_str()) {
paths.push(p.to_string());
}
}
}
}
paths
}
fn deps_from_npm(project_root: &Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(project_root.join("package.json")) else {
return vec![];
};
let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) else {
return vec![];
};
let mut paths = vec![];
for section in &["dependencies", "devDependencies", "peerDependencies"] {
if let Some(deps) = pkg.get(section).and_then(|v| v.as_object()) {
for val in deps.values() {
if let Some(s) = val.as_str() {
if let Some(path) = s
.strip_prefix("file:")
.or_else(|| s.strip_prefix("workspace:"))
{
if path.starts_with("../") || path.starts_with("./") {
paths.push(path.to_string());
}
}
}
}
}
}
paths
}
fn deps_from_pyproject(project_root: &Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(project_root.join("pyproject.toml")) else {
return vec![];
};
let Ok(table) = toml::from_str::<toml::Value>(&content) else {
return vec![];
};
let mut paths = vec![];
for dep in table
.get("tool")
.and_then(|t| t.get("poetry"))
.and_then(|p| p.get("dependencies"))
.and_then(|d| d.as_table())
.into_iter()
.flat_map(|t| t.values())
{
if let Some(p) = dep.get("path").and_then(|v| v.as_str()) {
paths.push(p.to_string());
}
}
for dep in table
.get("project")
.and_then(|p| p.get("dependencies"))
.and_then(|d| d.as_array())
.into_iter()
.flatten()
{
if let Some(s) = dep.as_str() {
if let Some(rest) = s.find("@ file:").map(|i| &s[i + 7..]) {
if rest.starts_with("../") || rest.starts_with("./") {
paths.push(rest.to_string());
}
}
}
}
paths
}
fn deps_from_requirements(project_root: &Path) -> Vec<String> {
let Ok(content) = std::fs::read_to_string(project_root.join("requirements.txt")) else {
return vec![];
};
content
.lines()
.filter_map(|line| {
let line = line.trim();
let path = line.strip_prefix("-e ")?;
if path.starts_with("../") || path.starts_with("./") {
Some(path.to_string())
} else {
None
}
})
.collect()
}
fn deps_from_gradle(project_root: &Path) -> Vec<String> {
let content = ["settings.gradle.kts", "settings.gradle"]
.iter()
.find_map(|f| std::fs::read_to_string(project_root.join(f)).ok())
.unwrap_or_default();
let mut paths = vec![];
for line in content.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("includeBuild(") {
let inner = rest.trim_end_matches(')').trim();
let path = inner
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')));
if let Some(p) = path {
if p.starts_with("../") || p.starts_with("./") {
paths.push(p.to_string());
}
}
}
}
paths
}
pub fn infer_depends_on(
project_root: &Path,
workspace_root: &Path,
all_projects: &[DiscoveredProject],
) -> Vec<String> {
let project_by_abs: std::collections::HashMap<PathBuf, &str> = all_projects
.iter()
.map(|p| (workspace_root.join(&p.relative_root), p.id.as_str()))
.collect();
let raw_paths: Vec<String> = [
deps_from_cargo(project_root),
deps_from_npm(project_root),
deps_from_pyproject(project_root),
deps_from_requirements(project_root),
deps_from_gradle(project_root),
]
.into_iter()
.flatten()
.collect();
let mut deps = std::collections::BTreeSet::new();
for raw in raw_paths {
let abs = normalize_path(project_root, &raw);
if let Some(&id) = project_by_abs.get(&abs) {
if abs != *project_root {
deps.insert(id.to_string());
}
}
}
deps.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn resolve_project_for_path_rejects_dotdot_escape() {
use std::path::PathBuf;
let workspace = PathBuf::from("/workspace");
let projects = vec![DiscoveredProject {
id: "project-a".into(),
relative_root: PathBuf::from("project-a"),
languages: vec!["rust".into()],
manifest: Some("Cargo.toml".into()),
}];
let escaped = PathBuf::from("/workspace/project-a/../../etc/passwd");
let resolved = resolve_project_for_path(&projects, &workspace, &escaped);
assert!(
resolved.is_none(),
"escape path must not match any project, got {:?}",
resolved.map(|p| &p.id)
);
let legit = PathBuf::from("/workspace/project-a/src/main.rs");
assert_eq!(
resolve_project_for_path(&projects, &workspace, &legit).map(|p| p.id.as_str()),
Some("project-a")
);
}
#[test]
fn discover_single_project_repo() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
let projects = discover_projects(dir.path(), 3, &[]);
assert_eq!(projects.len(), 1);
assert_eq!(
projects[0].id,
dir.path().file_name().unwrap().to_str().unwrap()
);
assert_eq!(projects[0].relative_root, std::path::Path::new("."));
assert_eq!(projects[0].manifest, Some("Cargo.toml".to_string()));
}
#[test]
fn discover_multi_project_repo() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
let mcp = dir.path().join("mcp-server");
fs::create_dir_all(&mcp).unwrap();
fs::write(mcp.join("package.json"), r#"{"scripts":{"build":"tsc"}}"#).unwrap();
let py = dir.path().join("python-services");
fs::create_dir_all(&py).unwrap();
fs::write(py.join("requirements.txt"), "flask\n").unwrap();
let projects = discover_projects(dir.path(), 3, &[]);
assert_eq!(projects.len(), 3);
assert_eq!(projects[0].relative_root, std::path::Path::new("."));
assert_eq!(projects[0].manifest, Some("build.gradle.kts".to_string()));
let ids: Vec<&str> = projects.iter().map(|p| p.id.as_str()).collect();
assert!(ids.contains(&"mcp-server"));
assert!(ids.contains(&"python-services"));
}
#[test]
fn skips_node_modules_manifests() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"scripts":{"test":"jest"}}"#,
)
.unwrap();
let nm = dir.path().join("node_modules").join("dep");
fs::create_dir_all(&nm).unwrap();
fs::write(nm.join("package.json"), r#"{"name":"dep"}"#).unwrap();
let projects = discover_projects(dir.path(), 3, &[]);
assert_eq!(projects.len(), 1); }
#[test]
fn respects_exclude_list() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("build.gradle.kts"), "").unwrap();
let tools = dir.path().join("tools");
fs::create_dir_all(&tools).unwrap();
fs::write(tools.join("requirements.txt"), "click\n").unwrap();
let projects = discover_projects(dir.path(), 3, &["tools".to_string()]);
assert_eq!(projects.len(), 1); }
#[test]
fn max_depth_limits_discovery() {
let dir = tempdir().unwrap();
let deep = dir
.path()
.join("a")
.join("b")
.join("c")
.join("deep-service");
fs::create_dir_all(&deep).unwrap();
fs::write(deep.join("Cargo.toml"), "[package]\nname = \"deep\"").unwrap();
let projects = discover_projects(dir.path(), 3, &[]);
assert!(
projects.is_empty(),
"manifest at depth 4 should be skipped with max_depth=3"
);
let projects = discover_projects(dir.path(), 5, &[]);
assert_eq!(projects.len(), 1);
}
#[test]
fn id_collision_is_deduplicated() {
let dir = tempdir().unwrap();
let svc_api = dir.path().join("services").join("api");
fs::create_dir_all(&svc_api).unwrap();
fs::write(svc_api.join("Cargo.toml"), "[package]\nname = \"svc-api\"").unwrap();
let tools_api = dir.path().join("tools").join("api");
fs::create_dir_all(&tools_api).unwrap();
fs::write(
tools_api.join("Cargo.toml"),
"[package]\nname = \"tools-api\"",
)
.unwrap();
let projects = discover_projects(dir.path(), 3, &[]);
let ids: Vec<&str> = projects.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert_ne!(ids[0], ids[1], "IDs must be unique: got {:?}", ids);
}
#[test]
fn resolve_project_from_path_uses_longest_prefix() {
let dir = tempdir().unwrap();
let projects = vec![
DiscoveredProject {
id: ROOT_PROJECT_ID.into(),
relative_root: ".".into(),
languages: vec!["kotlin".into()],
manifest: Some("build.gradle.kts".into()),
},
DiscoveredProject {
id: "mcp-server".into(),
relative_root: "mcp-server".into(),
languages: vec!["typescript".into()],
manifest: Some("package.json".into()),
},
];
let result = resolve_project_for_path(
&projects,
dir.path(),
&dir.path().join("mcp-server/src/index.ts"),
);
assert_eq!(result.unwrap().id, "mcp-server");
let result = resolve_project_for_path(
&projects,
dir.path(),
&dir.path().join("src/main/kotlin/App.kt"),
);
assert_eq!(result.unwrap().id, ROOT_PROJECT_ID);
}
fn make_project(id: &str, relative_root: &str) -> DiscoveredProject {
DiscoveredProject {
id: id.to_string(),
relative_root: PathBuf::from(relative_root),
languages: vec![],
manifest: None,
}
}
#[test]
fn infer_depends_on_cargo_path_dep() {
let dir = tempdir().unwrap();
let ws = dir.path();
let api = ws.join("api");
let shared = ws.join("shared");
fs::create_dir_all(&api).unwrap();
fs::create_dir_all(&shared).unwrap();
fs::write(
api.join("Cargo.toml"),
"[package]\nname = \"api\"\n\n[dependencies]\nshared = { path = \"../shared\" }\n",
)
.unwrap();
let projects = vec![make_project("api", "api"), make_project("shared", "shared")];
let deps = infer_depends_on(&api, ws, &projects);
assert_eq!(deps, vec!["shared"]);
}
#[test]
fn infer_depends_on_npm_workspace_protocol() {
let dir = tempdir().unwrap();
let ws = dir.path();
let web = ws.join("web");
let ui = ws.join("ui");
fs::create_dir_all(&web).unwrap();
fs::create_dir_all(&ui).unwrap();
fs::write(
web.join("package.json"),
r#"{"name":"web","scripts":{"build":"tsc"},"dependencies":{"@app/ui":"workspace:../ui"}}"#,
)
.unwrap();
let projects = vec![make_project("web", "web"), make_project("ui", "ui")];
let deps = infer_depends_on(&web, ws, &projects);
assert_eq!(deps, vec!["ui"]);
}
#[test]
fn infer_depends_on_requirements_txt_editable() {
let dir = tempdir().unwrap();
let ws = dir.path();
let svc = ws.join("svc");
let lib = ws.join("lib");
fs::create_dir_all(&svc).unwrap();
fs::create_dir_all(&lib).unwrap();
fs::write(svc.join("requirements.txt"), "-e ../lib\nrequests==2.31\n").unwrap();
let projects = vec![make_project("svc", "svc"), make_project("lib", "lib")];
let deps = infer_depends_on(&svc, ws, &projects);
assert_eq!(deps, vec!["lib"]);
}
#[test]
fn infer_depends_on_gradle_include_build() {
let dir = tempdir().unwrap();
let ws = dir.path();
let app = ws.join("app");
let core = ws.join("core");
fs::create_dir_all(&app).unwrap();
fs::create_dir_all(&core).unwrap();
fs::write(
app.join("settings.gradle.kts"),
"includeBuild(\"../core\")\n",
)
.unwrap();
let projects = vec![make_project("app", "app"), make_project("core", "core")];
let deps = infer_depends_on(&app, ws, &projects);
assert_eq!(deps, vec!["core"]);
}
#[test]
fn infer_depends_on_no_manifests_returns_empty() {
let dir = tempdir().unwrap();
let ws = dir.path();
let a = ws.join("a");
fs::create_dir_all(&a).unwrap();
let projects = vec![make_project("a", "a"), make_project("b", "b")];
let deps = infer_depends_on(&a, ws, &projects);
assert!(deps.is_empty());
}
#[test]
fn infer_depends_on_ignores_external_paths() {
let dir = tempdir().unwrap();
let ws = dir.path();
let api = ws.join("api");
fs::create_dir_all(&api).unwrap();
fs::write(
api.join("Cargo.toml"),
"[package]\nname = \"api\"\n\n[dependencies]\nexternal = { path = \"../../outside\" }\n",
)
.unwrap();
let projects = vec![make_project("api", "api")];
let deps = infer_depends_on(&api, ws, &projects);
assert!(deps.is_empty());
}
#[test]
fn package_json_without_scripts_or_main_is_skipped() {
let dir = tempdir().unwrap();
let sub = dir.path().join("data");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join("package.json"),
r#"{"name":"data","version":"1.0"}"#,
)
.unwrap();
let projects = discover_projects(dir.path(), 3, &[]);
assert!(projects.is_empty());
}
}