mod write_guard;
#[allow(unused_imports)]
pub(crate) use write_guard::{acquire as acquire_write_guard, open_lock_file, WriteGuard};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::project::ProjectConfig;
use crate::library::registry::LibraryRegistry;
use crate::memory::semantic_store::SemanticMemoryStore;
use crate::memory::MemoryStore;
use crate::workspace::{discover_projects, DiscoveredProject, Project, ProjectState, Workspace};
#[derive(Default, Clone)]
pub enum IndexingState {
#[default]
Idle,
Running {
done: usize,
total: usize,
eta_secs: Option<u64>,
},
Done {
files_indexed: usize,
files_deleted: usize,
detail: String,
total_files: usize,
total_chunks: usize,
},
Failed(String),
}
#[derive(Debug)]
pub enum LibraryIndexState {
Idle,
FetchingSources { command: String },
Indexing { done: usize, total: usize },
Done { chunks: usize, version: String },
Failed(String),
}
#[derive(Clone)]
pub struct Agent {
pub inner: Arc<RwLock<AgentInner>>,
pub indexing: Arc<std::sync::Mutex<IndexingState>>,
pub nudged_libraries: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
pub embedding_semaphore: Arc<tokio::sync::Semaphore>,
pub library_index_states: Arc<std::sync::Mutex<HashMap<String, LibraryIndexState>>>,
pub active_sync_abort: Arc<std::sync::Mutex<Option<tokio::task::AbortHandle>>>,
pub(crate) semantic_memory: Arc<tokio::sync::OnceCell<Arc<dyn SemanticMemoryStore>>>,
pub(crate) memory_embedder:
Arc<tokio::sync::OnceCell<Arc<dyn crate::retrieval::embedder::DenseEmbedder>>>,
}
pub struct AgentInner {
pub workspace: Option<Workspace>,
pub project_explicitly_activated: bool,
pub home_root: Option<PathBuf>,
}
impl AgentInner {
pub fn active_project(&self) -> Option<&ActiveProject> {
self.workspace.as_ref()?.focused_active()?.as_active()
}
pub fn active_project_mut(&mut self) -> Option<&mut ActiveProject> {
self.workspace
.as_mut()?
.focused_active_mut()?
.as_active_mut()
}
}
#[derive(Clone)]
pub struct ActiveProject {
pub(crate) root: PathBuf,
pub(crate) config: ProjectConfig,
pub(crate) memory: MemoryStore,
pub(crate) private_memory: MemoryStore,
pub(crate) library_registry: LibraryRegistry,
pub(crate) dirty_files: Arc<std::sync::Mutex<std::collections::HashSet<PathBuf>>>,
pub(crate) read_only: bool,
pub(crate) head_sha: Option<String>,
pub(crate) has_git_remote: bool,
pub(crate) write_lock: Arc<tokio::sync::Mutex<()>>,
pub(crate) file_lock: Arc<std::fs::File>,
pub(crate) session_write_roots: Arc<std::sync::Mutex<Vec<PathBuf>>>,
}
impl ActiveProject {
pub fn project_id(&self) -> &str {
&self.config.project.name
}
pub fn root(&self) -> &Path {
&self.root
}
}
fn load_discover_settings(root: &std::path::Path) -> (usize, Vec<String>) {
let ws_path = crate::config::workspace::workspace_config_path(root);
if let Ok(content) = std::fs::read_to_string(&ws_path) {
if let Ok(ws) = toml::from_str::<crate::config::workspace::WorkspaceConfig>(&content) {
return (ws.workspace.discovery_max_depth, ws.exclude_projects);
}
}
(3, vec![])
}
fn resolve_head_sha(root: &Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(root)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn probe_has_git_remote(root: &Path) -> bool {
git2::Repository::open(root)
.ok()
.and_then(|repo| repo.remotes().ok())
.map(|remotes| !remotes.is_empty())
.unwrap_or(false)
}
impl Agent {
pub async fn new(project: Option<PathBuf>) -> Result<Self> {
crate::install_default_crypto_provider();
let (workspace, home_root) = if let Some(raw) = project {
let root = std::fs::canonicalize(&raw).unwrap_or(raw);
let config = ProjectConfig::load_or_default(&root)?;
let memory = MemoryStore::open(&root)?;
let private_memory = MemoryStore::open_private(&root)?;
let registry_path = root.join(".codescout").join("libraries.json");
let library_registry = LibraryRegistry::load(®istry_path).unwrap_or_default();
let home = root.clone();
let active = ActiveProject {
root: root.clone(),
config,
memory,
private_memory,
library_registry,
dirty_files: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
read_only: false,
head_sha: resolve_head_sha(&root),
has_git_remote: probe_has_git_remote(&root),
write_lock: Arc::new(tokio::sync::Mutex::new(())),
file_lock: open_lock_file(&root)
.with_context(|| format!("failed to open write.lock for {}", root.display()))?,
session_write_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let (discover_depth, discover_exclude) = load_discover_settings(&root);
let discovered = {
let root = root.clone();
let exclude = discover_exclude.clone();
tokio::task::spawn_blocking(move || {
discover_projects(&root, discover_depth, &exclude)
})
.await
.map_err(|e| anyhow::anyhow!("discover_projects task failed: {e}"))?
};
let mut projects: Vec<Project> = Vec::new();
let mut root_found = false;
for dp in discovered {
if dp.relative_root == std::path::Path::new(".") {
root_found = true;
projects.push(Project {
discovered: dp,
state: ProjectState::Activated(Box::new(active.clone())),
});
} else {
projects.push(Project::new_dormant(dp));
}
}
if !root_found {
let root_dp = DiscoveredProject {
id: crate::workspace::ROOT_PROJECT_ID.to_string(),
relative_root: PathBuf::from("."),
languages: vec![],
manifest: None,
};
projects.insert(
0,
Project {
discovered: root_dp,
state: ProjectState::Activated(Box::new(active)),
},
);
}
let ws = Workspace::new(root, projects);
(Some(ws), Some(home))
} else {
(None, None)
};
let project_explicitly_activated = workspace.is_some();
Ok(Self {
inner: Arc::new(RwLock::new(AgentInner {
workspace,
project_explicitly_activated,
home_root,
})),
indexing: Arc::new(std::sync::Mutex::new(IndexingState::Idle)),
nudged_libraries: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
embedding_semaphore: Arc::new(tokio::sync::Semaphore::new(2)),
library_index_states: Arc::new(std::sync::Mutex::new(HashMap::new())),
active_sync_abort: Arc::new(std::sync::Mutex::new(None)),
semantic_memory: Arc::new(tokio::sync::OnceCell::new()),
memory_embedder: Arc::new(tokio::sync::OnceCell::new()),
})
}
pub async fn activate(&self, root: PathBuf, read_only: Option<bool>) -> Result<()> {
let root = std::fs::canonicalize(&root).unwrap_or(root);
let config = ProjectConfig::load_or_default(&root)?;
let memory = MemoryStore::open(&root)?;
let private_memory = MemoryStore::open_private(&root)?;
let registry_path = root.join(".codescout").join("libraries.json");
let library_registry = LibraryRegistry::load(®istry_path).unwrap_or_default();
let head_sha = resolve_head_sha(&root);
let (discover_depth, discover_exclude) = load_discover_settings(&root);
let discovered = {
let root = root.clone();
let exclude = discover_exclude.clone();
tokio::task::spawn_blocking(move || discover_projects(&root, discover_depth, &exclude))
.await
.map_err(|e| anyhow::anyhow!("discover_projects task failed: {e}"))?
};
let fresh_file_lock = write_guard::open_lock_file(&root)
.with_context(|| format!("failed to open write.lock for {}", root.display()))?;
{
let mut inner = self.inner.write().await;
let is_home = inner.home_root.as_ref().map(|h| *h == root).unwrap_or(true);
let effective_read_only = match read_only {
Some(false) => false,
_ if is_home => false,
_ => true,
};
let existing = inner.workspace.as_ref().and_then(|ws| {
ws.projects.iter().find_map(|p| match &p.state {
ProjectState::Activated(ap) if ap.root == root => Some((
ap.write_lock.clone(),
ap.file_lock.clone(),
ap.dirty_files.clone(),
)),
_ => None,
})
});
let (write_lock, file_lock, dirty_files) = existing.unwrap_or_else(|| {
(
Arc::new(tokio::sync::Mutex::new(())),
fresh_file_lock,
Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
)
});
let active = ActiveProject {
root: root.clone(),
config,
memory,
private_memory,
library_registry,
dirty_files,
read_only: effective_read_only,
head_sha,
has_git_remote: probe_has_git_remote(&root),
write_lock,
file_lock,
session_write_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let mut projects: Vec<Project> = Vec::new();
let mut root_found = false;
for dp in discovered {
if dp.relative_root == std::path::Path::new(".") {
root_found = true;
projects.push(Project {
discovered: dp,
state: ProjectState::Activated(Box::new(active.clone())),
});
} else {
projects.push(Project::new_dormant(dp));
}
}
if !root_found {
let root_dp = DiscoveredProject {
id: crate::workspace::ROOT_PROJECT_ID.to_string(),
relative_root: PathBuf::from("."),
languages: vec![],
manifest: None,
};
projects.insert(
0,
Project {
discovered: root_dp,
state: ProjectState::Activated(Box::new(active)),
},
);
}
let ws = Workspace::new(root.clone(), projects);
if inner.home_root.is_none() {
inner.home_root = Some(root);
}
inner.workspace = Some(ws);
inner.project_explicitly_activated = true;
}
Ok(())
}
pub async fn require_project_root(&self) -> Result<PathBuf> {
let inner = self.inner.read().await;
inner
.workspace
.as_ref()
.ok_or_else(|| {
crate::tools::RecoverableError::with_hint(
"No active project. Use activate_project first.",
"Call activate_project(\"/path/to/project\") to set the active project.",
)
})
.and_then(|ws| {
ws.focused_project_root().map_err(|_| {
crate::tools::RecoverableError::with_hint(
"No active project. Use activate_project first.",
"Call activate_project(\"/path/to/project\") to set the active project.",
)
})
})
.map_err(Into::into)
}
pub async fn switch_focus(&self, project_id: &str) -> Result<()> {
let mut inner = self.inner.write().await;
inner
.workspace
.as_mut()
.ok_or_else(|| anyhow::anyhow!("No active workspace"))?
.set_focused(project_id)
}
pub async fn activate_within_workspace(
&self,
project_id: &str,
read_only: Option<bool>,
) -> Result<()> {
let (abs_root, home_root_snapshot) = {
let inner = self.inner.read().await;
let ws = inner
.workspace
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No active workspace"))?;
let relative_root = ws
.projects
.iter()
.find(|p| p.discovered.id == project_id)
.map(|p| p.discovered.relative_root.clone())
.ok_or_else(|| {
anyhow::anyhow!("Project '{}' not found in workspace", project_id)
})?;
(ws.root.join(&relative_root), inner.home_root.clone())
};
let is_home_snapshot = home_root_snapshot
.as_ref()
.map(|h| *h == abs_root)
.unwrap_or(false);
let effective_read_only_snapshot = match read_only {
Some(false) => false,
_ if is_home_snapshot => false,
_ => true,
};
let _ = effective_read_only_snapshot;
let file_lock = write_guard::open_lock_file(&abs_root)
.with_context(|| format!("failed to open write.lock for {}", abs_root.display()))?;
let mut inner = self.inner.write().await;
let home_root = inner.home_root.clone();
let ws = inner
.workspace
.as_mut()
.ok_or_else(|| anyhow::anyhow!("No active workspace"))?;
let relative_root = ws
.projects
.iter()
.find(|p| p.discovered.id == project_id)
.map(|p| p.discovered.relative_root.clone())
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found in workspace", project_id))?;
let abs_root = ws.root.join(&relative_root);
let is_home = home_root.as_ref().map(|h| *h == abs_root).unwrap_or(false);
let effective_read_only = match read_only {
Some(false) => false,
_ if is_home => false,
_ => true,
};
let already_activated = ws
.projects
.iter()
.find(|p| p.discovered.id == project_id)
.and_then(|p| p.as_active())
.is_some();
if already_activated {
ws.set_focused(project_id)?;
if let Some(ro) = read_only {
if let Some(active) = ws.focused_active_mut().and_then(|p| p.as_active_mut()) {
active.read_only = ro;
}
}
return Ok(());
}
let config = ProjectConfig::load_or_default(&abs_root)?;
let memory = MemoryStore::open(&abs_root)?;
let private_memory = MemoryStore::open_private(&abs_root)?;
let registry_path = abs_root.join(".codescout").join("libraries.json");
let library_registry = LibraryRegistry::load(®istry_path).unwrap_or_default();
let head_sha = resolve_head_sha(&abs_root);
let active = ActiveProject {
root: abs_root.clone(),
config,
memory,
private_memory,
library_registry,
dirty_files: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
read_only: effective_read_only,
head_sha,
has_git_remote: probe_has_git_remote(&abs_root),
write_lock: Arc::new(tokio::sync::Mutex::new(())),
file_lock,
session_write_roots: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let project_mut = ws
.projects
.iter_mut()
.find(|p| p.discovered.id == project_id)
.expect("project_mut lookup — invariant: re-resolved from the same ws.projects slice under the write lock above; only activate_within_workspace mutates project list, and it holds this lock");
project_mut.state = ProjectState::Activated(Box::new(active));
ws.focused = Some(project_id.to_string());
Ok(())
}
pub async fn resolve_root(
&self,
project: Option<&str>,
file_hint: Option<&std::path::Path>,
) -> Result<PathBuf> {
let inner = self.inner.read().await;
inner
.workspace
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No active project"))?
.resolve_root(project, file_hint)
}
}
impl Agent {
pub async fn with_project<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&ActiveProject) -> Result<T>,
{
let inner = self.inner.read().await;
let project = inner
.active_project()
.ok_or_else(|| anyhow::anyhow!("No active project. Use activate_project first."))?;
f(project)
}
pub async fn mark_file_dirty(&self, path: PathBuf) {
let inner = self.inner.read().await;
if let Some(p) = inner.active_project() {
p.dirty_files
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(path);
}
}
pub async fn add_session_write_root(&self, path: PathBuf) {
let inner = self.inner.read().await;
if let Some(p) = inner.active_project() {
p.session_write_roots
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(path);
}
}
pub async fn session_write_roots_snapshot(&self) -> Vec<PathBuf> {
let inner = self.inner.read().await;
match inner.active_project() {
Some(p) => p
.session_write_roots
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone(),
None => Vec::new(),
}
}
pub async fn dirty_file_count(&self) -> usize {
let inner = self.inner.read().await;
inner
.active_project()
.map(|p| {
p.dirty_files
.lock()
.unwrap_or_else(|e| e.into_inner())
.len()
})
.unwrap_or(0)
}
pub async fn drain_dirty_files(&self) -> Vec<PathBuf> {
let inner = self.inner.read().await;
inner
.active_project()
.map(|p| {
let mut set = p.dirty_files.lock().unwrap_or_else(|e| e.into_inner());
set.drain().collect()
})
.unwrap_or_default()
}
pub async fn dirty_files_arc(
&self,
) -> Option<Arc<std::sync::Mutex<std::collections::HashSet<PathBuf>>>> {
let inner = self.inner.read().await;
inner.active_project().map(|p| p.dirty_files.clone())
}
pub async fn project_status(&self) -> Option<crate::prompts::ProjectStatus> {
let (name, path, languages, memory_store, db_path, prompt_file, default_prompt) = {
let inner = self.inner.read().await;
let project = inner.active_project()?;
let prompt_file = project.root.join(".codescout").join("system-prompt.md");
let db_path = project.root.join(".codescout/embeddings/project.db");
Some((
project.config.project.name.clone(),
project.root.display().to_string(),
project.config.project.languages.clone(),
project.memory.clone(),
db_path,
prompt_file,
project.config.project.system_prompt.clone(),
))
}?;
let (memories, has_index, system_prompt) = tokio::task::spawn_blocking(move || {
let memories = memory_store.list().unwrap_or_default();
let has_index = db_path.exists();
let system_prompt = if prompt_file.exists() {
std::fs::read_to_string(&prompt_file).ok()
} else {
default_prompt
};
(memories, has_index, system_prompt)
})
.await
.ok()?;
let workspace = self.workspace_summary().await;
Some(crate::prompts::ProjectStatus {
name,
path,
languages,
memories,
has_index,
system_prompt,
workspace,
})
}
pub fn index_status_label(&self) -> String {
match &*self.indexing.lock().unwrap() {
IndexingState::Idle => "idle".into(),
IndexingState::Running { .. } => "indexing".into(),
IndexingState::Done { .. } => "indexed".into(),
IndexingState::Failed(_) => "failed".into(),
}
}
pub async fn workspace_summary(&self) -> Option<Vec<crate::prompts::WorkspaceProjectSummary>> {
let inner = self.inner.read().await;
let ws = inner.workspace.as_ref()?;
if ws.projects.len() <= 1 {
return None;
}
let ws_cfg: Option<crate::config::workspace::WorkspaceConfig> =
std::fs::read_to_string(crate::config::workspace::workspace_config_path(&ws.root))
.ok()
.and_then(|s| toml::from_str(&s).ok());
let summaries = ws
.projects
.iter()
.map(|p| {
let depends_on = ws_cfg
.as_ref()
.and_then(|cfg| cfg.projects.iter().find(|e| e.id == p.discovered.id))
.map(|e| e.depends_on.clone())
.unwrap_or_default();
crate::prompts::WorkspaceProjectSummary {
id: p.discovered.id.clone(),
root: p.discovered.relative_root.display().to_string(),
languages: p.discovered.languages.clone(),
depends_on,
}
})
.collect();
Some(summaries)
}
pub async fn reload_config_if_project_toml(&self, path: &std::path::Path) {
let mut inner = self.inner.write().await;
if let Some(ref mut p) = inner.active_project_mut() {
let toml_path = p.root.join(".codescout").join("project.toml");
if path == toml_path {
if let Ok(fresh) = crate::config::project::ProjectConfig::load_or_default(&p.root) {
p.config = fresh;
}
}
}
}
pub async fn call_edges_project_id(&self) -> String {
let inner = self.inner.read().await;
inner
.workspace
.as_ref()
.and_then(|ws| ws.focused.clone())
.unwrap_or_else(|| crate::workspace::ROOT_PROJECT_ID.to_string())
}
pub async fn invalidate_call_edges(&self, path: &std::path::Path) {
let root = {
let inner = self.inner.read().await;
inner.active_project().map(|p| p.root.clone())
};
let Some(root) = root else { return };
let cache_db = root.join(".codescout/call_edges.db");
if !cache_db.exists() {
return;
}
let project_id = self.call_edges_project_id().await;
let path = path.to_path_buf();
let _ = tokio::task::spawn_blocking(move || {
let conn = match crate::tools::symbol::call_edges::cache::open_db(&root) {
Ok(c) => c,
Err(_) => return,
};
let cache = crate::tools::symbol::call_edges::cache::EdgeCache::new(&conn, &project_id);
let _ = cache.invalidate_file(&path);
})
.await;
}
}
impl Agent {
pub async fn project_root(&self) -> Option<PathBuf> {
let inner = self.inner.read().await;
inner.workspace.as_ref()?.focused_project_root().ok()
}
pub async fn is_project_explicitly_activated(&self) -> bool {
self.inner.read().await.project_explicitly_activated
}
pub async fn home_root(&self) -> Option<PathBuf> {
self.inner.read().await.home_root.clone()
}
pub async fn is_home(&self) -> bool {
let inner = self.inner.read().await;
match (inner.active_project(), &inner.home_root) {
(Some(project), Some(home)) => project.root == *home,
(None, None) => true,
_ => false,
}
}
pub async fn discovered_projects(&self) -> Vec<crate::workspace::DiscoveredProject> {
let inner = self.inner.read().await;
inner
.workspace
.as_ref()
.map(|ws| ws.projects.iter().map(|p| p.discovered.clone()).collect())
.unwrap_or_default()
}
pub async fn workspace_project_memories(&self) -> Vec<(String, Vec<String>)> {
let inner = self.inner.read().await;
let ws = match inner.workspace.as_ref() {
Some(ws) if ws.projects.len() > 1 => ws,
_ => return vec![],
};
ws.projects
.iter()
.filter_map(|p| {
let dir = ws.memory_dir_for_project(&p.discovered.id);
let topics = crate::memory::MemoryStore::from_dir(dir)
.ok()?
.list()
.unwrap_or_default();
if topics.is_empty() {
None
} else {
Some((p.discovered.id.clone(), topics))
}
})
.collect()
}
}
impl Agent {
pub async fn security_config(&self) -> crate::util::path_security::PathSecurityConfig {
let inner = self.inner.read().await;
match inner.active_project() {
Some(p) => {
let mut config = p.config.security.to_path_security_config();
config.library_paths = p
.library_registry
.all()
.iter()
.map(|e| e.path.clone())
.collect();
if p.read_only {
config.file_write_enabled = false;
}
config
}
None => crate::util::path_security::PathSecurityConfig::default(),
}
}
pub async fn lsp_mux_override(&self, language: &str) -> Option<bool> {
self.with_project(|p| Ok(p.config.lsp.langs.get(language).and_then(|o| o.mux)))
.await
.unwrap_or(None)
}
pub async fn library_registry(&self) -> Option<LibraryRegistry> {
self.inner
.read()
.await
.active_project()
.map(|p| p.library_registry.clone())
}
pub async fn save_library_registry(&self) -> Result<()> {
let inner = self.inner.read().await;
let project = inner
.active_project()
.ok_or_else(|| anyhow::anyhow!("No active project"))?;
let path = project.root.join(".codescout").join("libraries.json");
project.library_registry.save(&path)
}
}
impl Agent {
pub async fn should_nudge(&self, lib_name: &str) -> bool {
let inner = self.inner.read().await;
if let Some(p) = inner.active_project() {
if let Some(entry) = p.library_registry.lookup(lib_name) {
if entry.nudge_dismissed || entry.indexed {
return false;
}
}
}
drop(inner);
let mut nudged = self
.nudged_libraries
.lock()
.unwrap_or_else(|e| e.into_inner());
nudged.insert(lib_name.to_string())
}
pub fn set_library_state(&self, name: &str, state: LibraryIndexState) {
let mut states = self
.library_index_states
.lock()
.unwrap_or_else(|e| e.into_inner());
states.insert(name.to_string(), state);
}
pub async fn maybe_auto_index_library(&self, lib_name: &str) {
let (should_index, _root, entry_path) = {
let inner = self.inner.read().await;
let Some(p) = inner.active_project() else {
return;
};
if !p.config.libraries.auto_index {
return;
}
let Some(entry) = p.library_registry.lookup(lib_name) else {
return;
};
if entry.indexed {
return;
}
(true, p.root.clone(), entry.path.clone())
};
if !should_index {
return;
}
let name = lib_name.to_string();
let lib_project_id = format!("lib:{}", name);
self.set_library_state(&name, LibraryIndexState::Indexing { done: 0, total: 0 });
let self_clone = self.clone();
let sync_abort_for_task = self.active_sync_abort.clone();
let sync_abort_for_store = self.active_sync_abort.clone();
let task = tokio::spawn(async move {
tracing::info!("Auto-indexing library '{}' in background...", name);
let result = async {
let client = crate::retrieval::client::RetrievalClient::from_env().await?;
let opts = crate::retrieval::sync::SyncOpts::default();
client
.sync_project(&lib_project_id, &entry_path, opts)
.await
}
.await;
match result {
Ok(_report) => {
let mut inner = self_clone.inner.write().await;
if let Some(p) = inner.active_project_mut() {
if let Some(entry) = p.library_registry.lookup_mut(&name) {
entry.indexed = true;
}
let reg_path = p.root.join(".codescout/libraries.json");
let _ = p.library_registry.save(®_path);
}
drop(inner);
self_clone.set_library_state(
&name,
LibraryIndexState::Done {
chunks: 0,
version: String::new(),
},
);
}
Err(e) => {
self_clone.set_library_state(&name, LibraryIndexState::Failed(e.to_string()));
}
}
*sync_abort_for_task
.lock()
.unwrap_or_else(|e| e.into_inner()) = None;
});
*sync_abort_for_store
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(task.abort_handle());
}
pub fn library_states_summary(&self) -> HashMap<String, String> {
let states = self
.library_index_states
.lock()
.unwrap_or_else(|e| e.into_inner());
states
.iter()
.map(|(k, v)| {
let status = match v {
LibraryIndexState::Idle => "idle".to_string(),
LibraryIndexState::FetchingSources { command } => {
format!("fetching_sources: {}", command)
}
LibraryIndexState::Indexing { done, total } => {
format!("indexing: {}/{}", done, total)
}
LibraryIndexState::Done { chunks, version } => {
format!("done: {} chunks (v{})", chunks, version)
}
LibraryIndexState::Failed(msg) => format!("failed: {}", msg),
};
(k.clone(), status)
})
.collect()
}
}
impl Agent {
pub async fn semantic_memory_store(&self) -> anyhow::Result<Arc<dyn SemanticMemoryStore>> {
self.semantic_memory
.get_or_try_init(|| async {
let client = crate::retrieval::client::RetrievalClient::from_env().await?;
let collection = client.config.collection("memories");
let dim = client.config.model_dim as u64;
let store = crate::memory::semantic_store::QdrantSemanticMemoryStore::new(
client.qdrant,
collection,
dim,
)
.await?;
anyhow::Ok(Arc::new(store) as Arc<dyn SemanticMemoryStore>)
})
.await
.cloned()
}
#[cfg(test)]
pub fn set_semantic_memory_store_for_test(
&self,
store: Arc<dyn SemanticMemoryStore>,
) -> std::result::Result<(), tokio::sync::SetError<Arc<dyn SemanticMemoryStore>>> {
self.semantic_memory.set(store)
}
pub async fn memory_embedder(
&self,
) -> anyhow::Result<Arc<dyn crate::retrieval::embedder::DenseEmbedder>> {
self.memory_embedder
.get_or_try_init(|| async {
let client = crate::retrieval::client::RetrievalClient::from_env().await?;
let emb = crate::retrieval::embedder::HttpDenseEmbedder::new(client.embedder);
anyhow::Ok(Arc::new(emb) as Arc<dyn crate::retrieval::embedder::DenseEmbedder>)
})
.await
.cloned()
}
#[cfg(test)]
pub fn set_memory_embedder_for_test(
&self,
embedder: Arc<dyn crate::retrieval::embedder::DenseEmbedder>,
) -> std::result::Result<
(),
tokio::sync::SetError<Arc<dyn crate::retrieval::embedder::DenseEmbedder>>,
> {
self.memory_embedder.set(embedder)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn canonical(p: &std::path::Path) -> std::path::PathBuf {
std::fs::canonicalize(p).expect("path canonicalizes")
}
#[tokio::test]
async fn new_without_project() {
let agent = Agent::new(None).await.unwrap();
assert!(agent.require_project_root().await.is_err());
assert!(agent.project_status().await.is_none());
}
#[tokio::test]
async fn new_with_valid_project() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let root = agent.require_project_root().await.unwrap();
assert_eq!(root, canonical(dir.path()));
}
#[tokio::test]
async fn activate_sets_project() {
let agent = Agent::new(None).await.unwrap();
assert!(agent.require_project_root().await.is_err());
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let root = agent.require_project_root().await.unwrap();
assert_eq!(root, canonical(dir.path()));
}
#[tokio::test]
async fn activate_replaces_previous_project() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
assert_eq!(
agent.require_project_root().await.unwrap(),
canonical(dir1.path())
);
agent
.activate(dir2.path().to_path_buf(), None)
.await
.unwrap();
assert_eq!(
agent.require_project_root().await.unwrap(),
canonical(dir2.path())
);
}
#[tokio::test]
async fn require_project_root_error_message() {
let agent = Agent::new(None).await.unwrap();
let err = agent.require_project_root().await.unwrap_err();
assert!(
err.to_string().contains("No active project"),
"error should mention no active project: {}",
err
);
}
#[tokio::test]
async fn with_project_errors_when_none() {
let agent = Agent::new(None).await.unwrap();
let result = agent.with_project(|_p| Ok(42)).await;
assert!(result.is_err());
}
#[tokio::test]
async fn with_project_runs_closure() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let name = agent
.with_project(|p| Ok(p.config.project.name.clone()))
.await
.unwrap();
assert!(!name.is_empty());
}
#[tokio::test]
async fn project_status_returns_none_without_project() {
let agent = Agent::new(None).await.unwrap();
assert!(agent.project_status().await.is_none());
}
#[tokio::test]
async fn project_status_returns_some_with_project() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let status = agent.project_status().await;
assert!(status.is_some());
let status = status.unwrap();
assert!(!status.name.is_empty());
let canonical_dir = canonical(dir.path());
assert!(status.path.contains(canonical_dir.to_str().unwrap()));
}
#[tokio::test]
async fn agent_is_clone_safe() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(None).await.unwrap();
let agent2 = agent.clone();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let root = agent2.require_project_root().await.unwrap();
assert_eq!(root, canonical(dir.path()));
}
#[tokio::test]
async fn activate_creates_empty_library_registry() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let reg = agent.library_registry().await.unwrap();
assert!(
reg.all().is_empty(),
"fresh project should have empty library registry"
);
}
#[tokio::test]
async fn library_registry_none_without_project() {
let agent = Agent::new(None).await.unwrap();
assert!(agent.library_registry().await.is_none());
}
#[tokio::test]
async fn project_status_reads_system_prompt_file() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join(".codescout");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("project.toml"),
"[project]\nname = \"test\"\n",
)
.unwrap();
std::fs::write(config_dir.join("system-prompt.md"), "Always use pytest.\n").unwrap();
let agent = Agent::new(None).await.unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let status = agent.project_status().await.unwrap();
assert_eq!(
status.system_prompt.as_deref(),
Some("Always use pytest.\n")
);
}
#[tokio::test]
async fn project_status_falls_back_to_toml_system_prompt() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join(".codescout");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("project.toml"),
"[project]\nname = \"test\"\nsystem_prompt = \"From TOML\"\n",
)
.unwrap();
let agent = Agent::new(None).await.unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let status = agent.project_status().await.unwrap();
assert_eq!(status.system_prompt.as_deref(), Some("From TOML"));
}
#[tokio::test]
async fn project_status_file_takes_precedence_over_toml() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join(".codescout");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("project.toml"),
"[project]\nname = \"test\"\nsystem_prompt = \"From TOML\"\n",
)
.unwrap();
std::fs::write(config_dir.join("system-prompt.md"), "From file\n").unwrap();
let agent = Agent::new(None).await.unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let status = agent.project_status().await.unwrap();
assert_eq!(status.system_prompt.as_deref(), Some("From file\n"));
}
#[tokio::test]
async fn project_not_explicitly_activated_without_project() {
let agent = Agent::new(None).await.unwrap();
assert!(!agent.is_project_explicitly_activated().await);
}
#[tokio::test]
async fn activate_sets_explicitly_activated() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(None).await.unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
assert!(agent.is_project_explicitly_activated().await);
}
#[tokio::test]
async fn new_with_project_sets_explicitly_activated() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
assert!(agent.is_project_explicitly_activated().await);
}
#[tokio::test]
async fn home_root_set_from_initial_project() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
assert_eq!(agent.home_root().await, Some(canonical(dir.path())));
}
#[tokio::test]
async fn home_root_none_without_project() {
let agent = Agent::new(None).await.unwrap();
assert_eq!(agent.home_root().await, None);
}
#[tokio::test]
async fn home_root_set_on_first_activate() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(None).await.unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
assert_eq!(agent.home_root().await, Some(canonical(dir.path())));
}
#[tokio::test]
async fn home_root_not_changed_by_second_activate() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
agent
.activate(dir2.path().to_path_buf(), None)
.await
.unwrap();
assert_eq!(agent.home_root().await, Some(canonical(dir1.path())));
}
#[tokio::test]
async fn is_home_true_when_at_home() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
assert!(agent.is_home().await);
}
#[tokio::test]
async fn is_home_false_after_switching() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
agent
.activate(dir2.path().to_path_buf(), None)
.await
.unwrap();
assert!(!agent.is_home().await);
}
#[tokio::test]
async fn is_home_true_after_returning() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
agent
.activate(dir2.path().to_path_buf(), None)
.await
.unwrap();
assert!(!agent.is_home().await);
agent
.activate(dir1.path().to_path_buf(), None)
.await
.unwrap();
assert!(agent.is_home().await);
}
#[tokio::test]
async fn new_with_relative_path_canonicalizes_home_root() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let parent = canonical.parent().unwrap();
let rel = canonical.file_name().unwrap();
let orig_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(parent).unwrap();
let agent = Agent::new(Some(PathBuf::from(rel))).await.unwrap();
std::env::set_current_dir(&orig_cwd).unwrap();
let home = agent.home_root().await.unwrap();
assert!(
home.is_absolute(),
"home_root should be absolute, got: {}",
home.display()
);
assert_eq!(home, canonical);
agent.activate(canonical.clone(), None).await.unwrap();
assert!(
agent.is_home().await,
"is_home must be true after re-activating the same directory"
);
}
#[tokio::test]
async fn active_project_has_private_memory() {
let dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
agent
.with_project(|p| {
p.private_memory.write("pref", "verbose")?;
assert_eq!(p.private_memory.read("pref")?, Some("verbose".to_string()));
assert_eq!(p.memory.read("pref")?, None);
Ok(())
})
.await
.unwrap();
}
#[tokio::test]
async fn project_root_matches_require_project_root_after_switch_focus() {
let dir = tempdir().unwrap();
let root = dir.path().to_path_buf();
let sub = root.join("packages").join("api");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
sub.join("package.json"),
r#"{"name":"api","scripts":{"build":"tsc"}}"#,
)
.unwrap();
let agent = Agent::new(Some(root.clone())).await.unwrap();
let pr = agent.project_root().await;
let rpr = agent.require_project_root().await.unwrap();
assert!(
pr.is_some(),
"project_root() must be Some before switch_focus"
);
assert_eq!(
pr.unwrap(),
rpr,
"project_root() and require_project_root() must agree before switch_focus"
);
agent.switch_focus("api").await.unwrap();
let pr_after = agent.project_root().await;
let rpr_after = agent.require_project_root().await.unwrap();
assert!(
pr_after.is_some(),
"project_root() must not be None after switch_focus (Dormant-project bug)"
);
assert_eq!(
pr_after.unwrap(),
rpr_after,
"project_root() and require_project_root() must agree after switch_focus"
);
assert!(
rpr_after.ends_with("packages/api"),
"focused root must be the sub-project: {:?}",
rpr_after
);
}
#[tokio::test]
async fn activate_non_home_defaults_to_read_only() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
agent
.activate(dir2.path().to_path_buf(), None)
.await
.unwrap();
let config = agent.security_config().await;
assert!(
!config.file_write_enabled,
"non-home project should be read-only by default"
);
}
#[tokio::test]
async fn activate_non_home_with_read_only_false_is_writable() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
agent
.activate(dir2.path().to_path_buf(), Some(false))
.await
.unwrap();
let config = agent.security_config().await;
assert!(
config.file_write_enabled,
"explicit read_only=false should enable writes"
);
}
#[tokio::test]
async fn activate_home_always_writable() {
let dir1 = tempdir().unwrap();
let dir2 = tempdir().unwrap();
std::fs::create_dir_all(dir1.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir2.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir1.path().to_path_buf())).await.unwrap();
agent
.activate(dir2.path().to_path_buf(), None)
.await
.unwrap();
assert!(!agent.security_config().await.file_write_enabled);
agent
.activate(dir1.path().to_path_buf(), None)
.await
.unwrap();
assert!(
agent.security_config().await.file_write_enabled,
"home project should always be writable"
);
}
#[tokio::test]
async fn first_activate_is_writable() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(None).await.unwrap();
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let config = agent.security_config().await;
assert!(
config.file_write_enabled,
"first activated project should be writable (becomes home)"
);
}
#[tokio::test]
async fn workspace_summary_returns_projects_with_depends_on() {
let dir = tempdir().unwrap();
let root = dir.path().to_path_buf();
let sub_a = root.join("packages").join("api");
let sub_b = root.join("packages").join("web");
std::fs::create_dir_all(&sub_a).unwrap();
std::fs::create_dir_all(&sub_b).unwrap();
std::fs::write(
sub_a.join("package.json"),
r#"{"name":"api","scripts":{"build":"tsc"}}"#,
)
.unwrap();
std::fs::write(
sub_b.join("package.json"),
r#"{"name":"web","scripts":{"build":"tsc"}}"#,
)
.unwrap();
let agent = Agent::new(Some(root)).await.unwrap();
let summary = agent.workspace_summary().await;
assert!(
summary.is_some(),
"multi-project workspace should have summary"
);
let projects = summary.unwrap();
assert!(projects.len() >= 2, "should have at least 2 sub-projects");
for p in &projects {
let _ = &p.depends_on;
}
}
#[tokio::test]
async fn workspace_summary_returns_none_for_single_project() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let summary = agent.workspace_summary().await;
assert!(
summary.is_none(),
"single-project workspace should return None"
);
}
#[tokio::test]
async fn activate_within_workspace_promotes_dormant() {
let dir = tempdir().unwrap();
let root = dir.path().to_path_buf();
let sub = root.join("packages").join("api");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(
sub.join("package.json"),
r#"{"name":"api","scripts":{"build":"tsc"}}"#,
)
.unwrap();
let agent = Agent::new(Some(root.clone())).await.unwrap();
agent.switch_focus("api").await.unwrap();
let is_dormant = {
let inner = agent.inner.read().await;
inner.active_project().is_none()
};
assert!(
is_dormant,
"sub-project should be Dormant before activate_within_workspace"
);
agent
.switch_focus(crate::workspace::ROOT_PROJECT_ID)
.await
.unwrap();
agent.activate_within_workspace("api", None).await.unwrap();
let name = agent
.with_project(|p| Ok(p.config.project.name.clone()))
.await
.unwrap();
assert!(
!name.is_empty(),
"should have loaded config for sub-project"
);
let project_count = {
let inner = agent.inner.read().await;
inner.workspace.as_ref().unwrap().projects.len()
};
assert!(
project_count >= 2,
"workspace should still have all projects"
);
}
#[tokio::test]
async fn activate_within_workspace_unknown_id_errors() {
let dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let result = agent.activate_within_workspace("nonexistent", None).await;
assert!(result.is_err());
}
#[tokio::test]
async fn activate_populates_head_sha() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir.path())
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.output()
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let sha = agent
.with_project(|p| Ok(p.head_sha.clone()))
.await
.unwrap();
assert!(sha.is_some(), "head_sha should be set for a git project");
assert!(
sha.as_ref().unwrap().len() >= 7,
"SHA should be at least 7 chars"
);
}
#[tokio::test]
async fn head_sha_none_for_non_git_project() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let sha = agent
.with_project(|p| Ok(p.head_sha.clone()))
.await
.unwrap();
assert!(sha.is_none(), "head_sha should be None for non-git project");
}
#[tokio::test]
async fn drain_dirty_files_clears_set_and_returns_paths() {
use std::path::PathBuf;
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let a = PathBuf::from("/proj/src/a.rs");
let b = PathBuf::from("/proj/src/b.rs");
agent.mark_file_dirty(a.clone()).await;
agent.mark_file_dirty(b.clone()).await;
let mut drained = agent.drain_dirty_files().await;
drained.sort();
assert_eq!(drained, vec![a, b]);
assert!(agent.drain_dirty_files().await.is_empty());
}
#[tokio::test]
async fn session_write_roots_empty_by_default() {
let dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let roots = agent.session_write_roots_snapshot().await;
assert!(roots.is_empty());
}
#[tokio::test]
async fn add_session_write_root_visible_in_snapshot() {
let dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let extra = dir.path().join("extra");
agent.add_session_write_root(extra.clone()).await;
let roots = agent.session_write_roots_snapshot().await;
assert_eq!(roots, vec![extra]);
}
#[tokio::test]
async fn session_write_roots_cleared_on_reactivation() {
let dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let extra = dir.path().join("extra");
agent.add_session_write_root(extra.clone()).await;
let roots = agent.session_write_roots_snapshot().await;
assert!(
!roots.is_empty(),
"root should be visible before re-activation"
);
agent
.activate(dir.path().to_path_buf(), None)
.await
.unwrap();
let roots_after = agent.session_write_roots_snapshot().await;
assert!(
roots_after.is_empty(),
"session roots must clear on re-activation"
);
}
}