use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Serialize;
use crate::state::AppState;
const MAX_FILE_PREVIEW_BYTES: usize = 500;
const MAX_DESC_LENGTH: usize = 120;
#[derive(Clone, Debug, Serialize)]
pub struct ProjectInfo {
pub name: String,
pub dir: PathBuf,
pub description: Option<String>,
pub has_assistant_history: bool,
}
pub fn scan_projects_dir(projects_dir: &Path) -> HashMap<String, ProjectInfo> {
let mut index = HashMap::new();
let entries = match std::fs::read_dir(projects_dir) {
Ok(e) => e,
Err(e) => {
tracing::warn!(
"failed to scan projects_dir {}: {e}",
projects_dir.display()
);
return index;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
let has_assistant_history = path.join(".claude").is_dir();
let description = extract_description(&path);
index.insert(
name.to_string(),
ProjectInfo {
name: name.to_string(),
dir: path,
description,
has_assistant_history,
},
);
}
tracing::info!(
"indexed {} projects from {}",
index.len(),
projects_dir.display()
);
index
}
fn extract_description(dir: &Path) -> Option<String> {
for filename in &["AGENTS.md", "CLAUDE.md", "README.md"] {
let path = dir.join(filename);
if let Ok(content) = std::fs::read_to_string(&path) {
let mut end = content.len().min(MAX_FILE_PREVIEW_BYTES);
while end > 0 && !content.is_char_boundary(end) {
end -= 1;
}
let content = &content[..end];
if let Some(line) = first_meaningful_line(content) {
return Some(line);
}
}
}
None
}
fn first_meaningful_line(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("---") || trimmed.starts_with("===") {
continue;
}
if trimmed.starts_with("<!--") || trimmed.starts_with('<') {
continue;
}
if trimmed == "@AGENTS.md" {
continue;
}
if trimmed.starts_with("[![") || trimmed.starts_with("![") {
continue;
}
let desc = if trimmed.len() > MAX_DESC_LENGTH {
format!("{}...", &trimmed[..MAX_DESC_LENGTH - 3])
} else {
trimmed.to_string()
};
return Some(desc);
}
None
}
pub fn resolve_projects_dir(projects_dir: &Option<String>) -> Option<PathBuf> {
let dir = projects_dir.as_ref()?;
let expanded = PathBuf::from(crate::state::expand_tilde(dir));
if expanded.is_dir() {
Some(expanded)
} else {
None
}
}
pub async fn refresh_index(state: &Arc<AppState>) {
let projects_dir = {
let settings = state.settings.read().await;
resolve_projects_dir(&settings.projects_dir)
};
let Some(projects_dir) = projects_dir else {
return;
};
let index = tokio::task::spawn_blocking(move || scan_projects_dir(&projects_dir))
.await
.unwrap_or_default();
*state.project_index.write().await = index;
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn first_meaningful_line_skips_headings() {
let content = "# My Project\n\nA cool Rust library for things.";
assert_eq!(
first_meaningful_line(content),
Some("A cool Rust library for things.".into())
);
}
#[test]
fn first_meaningful_line_returns_none_for_empty() {
assert_eq!(first_meaningful_line(""), None);
assert_eq!(first_meaningful_line("# Just a heading\n---"), None);
}
#[test]
fn first_meaningful_line_truncates_long() {
let long = "x".repeat(200);
let result = first_meaningful_line(&long).unwrap();
assert!(result.ends_with("..."));
assert!(result.len() <= 121);
}
#[test]
fn scan_finds_projects() {
let tmp = tempfile::tempdir().unwrap();
let proj_a = tmp.path().join("project-a");
let proj_b = tmp.path().join("project-b");
fs::create_dir(&proj_a).unwrap();
fs::create_dir(&proj_b).unwrap();
fs::create_dir(proj_a.join(".claude")).unwrap();
fs::write(proj_b.join("README.md"), "# Hello\n\nA web app.\n").unwrap();
fs::create_dir(tmp.path().join(".hidden")).unwrap();
let index = scan_projects_dir(tmp.path());
assert_eq!(index.len(), 2);
assert!(index["project-a"].has_assistant_history);
assert!(!index["project-b"].has_assistant_history);
assert_eq!(
index["project-b"].description.as_deref(),
Some("A web app.")
);
assert!(!index.contains_key(".hidden"));
}
#[test]
fn extract_description_prefers_agents_over_thin_claude_include() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("AGENTS.md"),
"# Agent guidance\n\nCanonical project description.\n",
)
.unwrap();
fs::write(tmp.path().join("CLAUDE.md"), "@AGENTS.md\n").unwrap();
fs::write(
tmp.path().join("README.md"),
"# Project\n\nREADME fallback description.\n",
)
.unwrap();
assert_eq!(
extract_description(tmp.path()).as_deref(),
Some("Canonical project description.")
);
}
#[test]
fn extract_description_skips_thin_claude_include_without_agents() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("CLAUDE.md"), "@AGENTS.md\n").unwrap();
fs::write(
tmp.path().join("README.md"),
"# Project\n\nREADME fallback description.\n",
)
.unwrap();
assert_eq!(
extract_description(tmp.path()).as_deref(),
Some("README fallback description.")
);
}
#[test]
fn resolve_projects_dir_none() {
assert!(resolve_projects_dir(&None).is_none());
}
#[test]
fn resolve_projects_dir_nonexistent() {
assert!(resolve_projects_dir(&Some("/nonexistent/path/xyz".into())).is_none());
}
}