blue/crates/blue-core/src/state.rs
Eric Garcia 2fdf29d56e feat(core): Implement RFC 0003 per-repo .blue/ folders
Simplify Blue's directory structure to use per-repo .blue/ folders
instead of centralized ~/.blue/repos/<project>/ structure.

Changes:
- Refactor BlueHome struct with simplified per-repo paths
- Update detect_blue() to find git root and create .blue/ there
- Add migration logic from old .blue/repos/<project>/docs/ structure
- Auto-create .blue/ on first command (no 'blue init' required)
- Update all handlers to use new flat path structure
- Handle edge case: no git repo uses current directory

Structure: repo/.blue/{docs/, worktrees/, blue.db, config.yaml}

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:41:17 -05:00

281 lines
8.2 KiB
Rust

//! Project state management for Blue
//!
//! Aggregates documents, worktrees, and provides status views.
use std::collections::HashSet;
use serde::Serialize;
use tracing::warn;
use crate::repo::{list_worktrees, BlueHome, WorktreeInfo};
use crate::store::{DocType, DocumentStore, StoreError};
/// Complete project state
#[derive(Debug)]
pub struct ProjectState {
/// Blue's home directory
pub home: BlueHome,
/// SQLite document store
pub store: DocumentStore,
/// Active git worktrees
pub worktrees: Vec<WorktreeInfo>,
/// Set of RFC titles with active worktrees
worktree_rfcs: HashSet<String>,
/// Project name
pub project: String,
}
impl ProjectState {
/// Create a test state with in-memory store (for testing only)
#[cfg(any(test, feature = "test-helpers"))]
pub fn for_test() -> Self {
use std::path::PathBuf;
let store = DocumentStore::open_in_memory().unwrap();
let root = PathBuf::from("/test");
let blue_dir = root.join(".blue");
Self {
home: BlueHome {
root: root.clone(),
blue_dir: blue_dir.clone(),
docs_path: blue_dir.join("docs"),
db_path: blue_dir.join("blue.db"),
worktrees_path: blue_dir.join("worktrees"),
project_name: Some("test".to_string()),
migrated: false,
},
store,
worktrees: Vec::new(),
worktree_rfcs: HashSet::new(),
project: "test".to_string(),
}
}
/// Load project state
///
/// Note: `project` parameter is kept for API compatibility but is no longer
/// used for path resolution (RFC 0003 - per-repo structure)
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
// Ensure directories exist (auto-create per RFC 0003)
home.ensure_dirs().map_err(StateError::Io)?;
// Use db_path directly from BlueHome (no project subdirectory)
let store = DocumentStore::open(&home.db_path)?;
// Discover worktrees
let worktrees = Self::discover_worktrees(&home);
let worktree_rfcs: HashSet<String> =
worktrees.iter().filter_map(|wt| wt.rfc_title()).collect();
Ok(Self {
home,
store,
worktrees,
worktree_rfcs,
project: project.to_string(),
})
}
/// Discover worktrees from the repo
fn discover_worktrees(home: &BlueHome) -> Vec<WorktreeInfo> {
// Try to open git repo from root
if let Ok(repo) = git2::Repository::discover(&home.root) {
return list_worktrees(&repo);
}
Vec::new()
}
/// Reload state from disk
pub fn reload(&mut self) -> Result<(), StateError> {
self.worktrees = Self::discover_worktrees(&self.home);
self.worktree_rfcs = self
.worktrees
.iter()
.filter_map(|wt| wt.rfc_title())
.collect();
Ok(())
}
/// Get RFCs that are in-progress with active worktrees
pub fn active_items(&self) -> Vec<WorkItem> {
match self.store.list_documents_by_status(DocType::Rfc, "in-progress") {
Ok(docs) => docs
.into_iter()
.filter(|doc| self.worktree_rfcs.contains(&doc.title))
.map(|doc| WorkItem {
title: doc.title,
status: doc.status,
has_worktree: true,
item_type: ItemType::Rfc,
})
.collect(),
Err(e) => {
warn!("Couldn't get active items: {}", e);
Vec::new()
}
}
}
/// Get RFCs that are accepted and ready to start
pub fn ready_items(&self) -> Vec<WorkItem> {
match self.store.list_documents_by_status(DocType::Rfc, "accepted") {
Ok(docs) => docs
.into_iter()
.map(|doc| WorkItem {
title: doc.title,
status: doc.status,
has_worktree: false,
item_type: ItemType::Rfc,
})
.collect(),
Err(e) => {
warn!("Couldn't get ready items: {}", e);
Vec::new()
}
}
}
/// Get RFCs that are in-progress but have no worktree (possibly stalled)
pub fn stalled_items(&self) -> Vec<WorkItem> {
match self.store.list_documents_by_status(DocType::Rfc, "in-progress") {
Ok(docs) => docs
.into_iter()
.filter(|doc| !self.worktree_rfcs.contains(&doc.title))
.map(|doc| WorkItem {
title: doc.title,
status: doc.status,
has_worktree: false,
item_type: ItemType::Rfc,
})
.collect(),
Err(e) => {
warn!("Couldn't get stalled items: {}", e);
Vec::new()
}
}
}
/// Get draft RFCs
pub fn draft_items(&self) -> Vec<WorkItem> {
match self.store.list_documents_by_status(DocType::Rfc, "draft") {
Ok(docs) => docs
.into_iter()
.map(|doc| WorkItem {
title: doc.title,
status: doc.status,
has_worktree: false,
item_type: ItemType::Rfc,
})
.collect(),
Err(e) => {
warn!("Couldn't get draft items: {}", e);
Vec::new()
}
}
}
/// Check if an RFC has an active worktree
pub fn has_worktree(&self, rfc_title: &str) -> bool {
self.worktree_rfcs.contains(rfc_title)
}
/// Generate a status hint for the user
pub fn generate_hint(&self) -> String {
let active = self.active_items();
let ready = self.ready_items();
let stalled = self.stalled_items();
if !stalled.is_empty() {
return format!(
"'{}' might be stalled - it's in-progress but has no worktree",
stalled[0].title
);
}
if !ready.is_empty() {
return format!("'{}' is ready to implement. Want to start?", ready[0].title);
}
if !active.is_empty() {
return format!("{} item(s) currently in progress", active.len());
}
"Nothing pressing right now. Good time to plan?".to_string()
}
/// Get project status summary
pub fn status_summary(&self) -> StatusSummary {
let active = self.active_items();
let ready = self.ready_items();
let stalled = self.stalled_items();
let drafts = self.draft_items();
StatusSummary {
active_count: active.len(),
ready_count: ready.len(),
stalled_count: stalled.len(),
draft_count: drafts.len(),
active,
ready,
stalled,
drafts,
hint: self.generate_hint(),
}
}
}
/// A work item (RFC, spike, etc.)
#[derive(Debug, Clone, Serialize)]
pub struct WorkItem {
pub title: String,
pub status: String,
pub has_worktree: bool,
pub item_type: ItemType,
}
/// Type of work item
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ItemType {
Rfc,
Spike,
Adr,
Decision,
Prd,
}
/// Project status summary
#[derive(Debug, Clone, Serialize)]
pub struct StatusSummary {
pub active_count: usize,
pub ready_count: usize,
pub stalled_count: usize,
pub draft_count: usize,
pub active: Vec<WorkItem>,
pub ready: Vec<WorkItem>,
pub stalled: Vec<WorkItem>,
pub drafts: Vec<WorkItem>,
pub hint: String,
}
/// State errors
#[derive(Debug, thiserror::Error)]
pub enum StateError {
#[error("Store error: {0}")]
Store(#[from] StoreError),
#[error("Repo error: {0}")]
Repo(#[from] crate::repo::RepoError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
#[test]
fn test_generate_hint_empty() {
// This would require setting up a full test environment
// For now, just verify the function exists and compiles
}
}