use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use super::task::AgentType;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ForgeState {
pub version: String,
pub project_name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub tools: Vec<DetectedTool>,
pub brain: BrainConfig,
pub task_summary: TaskSummary,
pub active_locks: HashMap<String, FileLock>,
#[serde(default)]
pub agent_auth: HashMap<String, String>,
#[serde(default)]
pub agent_permissions: HashMap<String, String>,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default = "default_dashboard_mode")]
pub dashboard_mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_phase: Option<String>,
}
fn default_dashboard_mode() -> String {
"piped".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedTool {
pub name: String,
pub agent_type: AgentType,
pub version: Option<String>,
pub path: String,
pub available: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainConfig {
pub provider: String,
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TaskSummary {
pub total: usize,
pub pending: usize,
pub in_progress: usize,
pub completed: usize,
pub failed: usize,
pub blocked: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileLock {
pub task_id: String,
pub agent: AgentType,
pub locked_at: DateTime<Utc>,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitConfig {
pub auto_commit: bool,
pub strategy: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerConfig {
pub rotation: bool,
pub pacing_min_secs: u64,
pub pacing_max_secs: u64,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
rotation: false,
pacing_min_secs: 64,
pacing_max_secs: 179,
}
}
}
impl Default for GitConfig {
fn default() -> Self {
Self {
auto_commit: true,
strategy: "single".to_string(),
}
}
}
impl Default for ForgeState {
fn default() -> Self {
let now = Utc::now();
Self {
version: "1.1.0".into(),
project_name: String::new(),
created_at: now,
updated_at: now,
tools: Vec::new(),
brain: BrainConfig {
provider: "rule-based".into(),
model: None,
},
task_summary: TaskSummary::default(),
active_locks: HashMap::new(),
agent_auth: HashMap::new(),
agent_permissions: HashMap::new(),
git: GitConfig::default(),
scheduler: SchedulerConfig::default(),
dashboard_mode: default_dashboard_mode(),
current_phase: None,
}
}
}
pub struct StateManager {
forge_dir: PathBuf,
}
impl StateManager {
pub fn new(forge_dir: impl Into<PathBuf>) -> Self {
Self {
forge_dir: forge_dir.into(),
}
}
fn state_path(&self) -> PathBuf {
self.forge_dir.join("state.json")
}
pub fn load(&self) -> anyhow::Result<ForgeState> {
let path = self.state_path();
if !path.exists() {
return Ok(ForgeState::default());
}
let content = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&content)?)
}
pub fn save(&self, state: &ForgeState) -> anyhow::Result<()> {
std::fs::create_dir_all(&self.forge_dir)?;
let content = serde_json::to_string_pretty(state)?;
std::fs::write(self.state_path(), content)?;
Ok(())
}
pub fn update_task_summary(&self, summary: TaskSummary) -> anyhow::Result<()> {
let mut state = self.load()?;
state.task_summary = summary;
state.updated_at = Utc::now();
self.save(&state)
}
pub fn refresh_task_summary(&self) -> anyhow::Result<()> {
let task_mgr = super::task::TaskManager::new(&self.forge_dir);
let tasks = task_mgr.list_tasks()?;
let summary = TaskSummary {
total: tasks.len(),
pending: tasks
.iter()
.filter(|t| t.status == super::task::TaskStatus::Pending)
.count(),
in_progress: tasks
.iter()
.filter(|t| {
t.status == super::task::TaskStatus::Assigned
|| t.status == super::task::TaskStatus::InProgress
})
.count(),
completed: tasks
.iter()
.filter(|t| t.status == super::task::TaskStatus::Completed)
.count(),
failed: tasks
.iter()
.filter(|t| t.status == super::task::TaskStatus::Failed)
.count(),
blocked: tasks
.iter()
.filter(|t| t.status == super::task::TaskStatus::Blocked)
.count(),
};
self.update_task_summary(summary)
}
pub fn update_tools(&self, tools: &[DetectedTool]) -> anyhow::Result<()> {
let mut state = self.load()?;
state.tools = tools.to_vec();
state.updated_at = Utc::now();
self.save(&state)
}
pub fn check_file_conflicts(&self, files: &[String]) -> anyhow::Result<Vec<FileLock>> {
let state = self.load()?;
let conflicts: Vec<FileLock> = state
.active_locks
.values()
.filter(|lock| lock.files.iter().any(|f| files.contains(f)))
.cloned()
.collect();
Ok(conflicts)
}
pub fn lock_files(
&self,
task_id: &str,
agent: AgentType,
files: Vec<String>,
) -> anyhow::Result<()> {
let mut state = self.load()?;
state.active_locks.insert(
task_id.to_string(),
FileLock {
task_id: task_id.to_string(),
agent,
locked_at: Utc::now(),
files,
},
);
state.updated_at = Utc::now();
self.save(&state)
}
pub fn get_agent_auth(&self, agent: &str) -> anyhow::Result<String> {
let state = self.load()?;
Ok(state
.agent_auth
.get(agent)
.cloned()
.unwrap_or_else(|| "subscription".to_string()))
}
pub fn get_agent_permissions(&self, agent: &str) -> anyhow::Result<String> {
let state = self.load()?;
Ok(state
.agent_permissions
.get(agent)
.cloned()
.unwrap_or_else(|| "safe".to_string()))
}
pub fn unlock_files(&self, task_id: &str) -> anyhow::Result<()> {
let mut state = self.load()?;
state.active_locks.remove(task_id);
state.updated_at = Utc::now();
self.save(&state)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::task::{AgentType, Task, TaskStatus};
#[test]
fn test_git_config_default() {
let cfg = GitConfig::default();
assert!(cfg.auto_commit);
assert_eq!(cfg.strategy, "single");
}
#[test]
fn test_forge_state_default_has_git() {
let state = ForgeState::default();
assert!(state.git.auto_commit);
assert_eq!(state.git.strategy, "single");
}
#[test]
fn test_state_without_git_field_deserializes() {
let json = r#"{
"version": "0.2.0",
"project_name": "test",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"tools": [],
"brain": { "provider": "rule-based", "model": null },
"task_summary": { "total": 0, "pending": 0, "in_progress": 0, "completed": 0, "failed": 0, "blocked": 0 },
"active_locks": {}
}"#;
let state: ForgeState = serde_json::from_str(json).unwrap();
assert!(state.git.auto_commit);
assert_eq!(state.git.strategy, "single");
}
#[test]
fn test_config_roundtrip_with_git() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state.git.auto_commit = false;
mgr.save(&state).unwrap();
let loaded = mgr.load().unwrap();
assert!(!loaded.git.auto_commit);
}
#[test]
fn test_dashboard_mode_default() {
let state = ForgeState::default();
assert_eq!(state.dashboard_mode, "piped");
}
#[test]
fn test_dashboard_mode_backward_compat() {
let json = r#"{
"version": "1.2.0",
"project_name": "test",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"tools": [],
"brain": { "provider": "rule-based", "model": null },
"task_summary": { "total": 0, "pending": 0, "in_progress": 0, "completed": 0, "failed": 0, "blocked": 0 },
"active_locks": {}
}"#;
let state: ForgeState = serde_json::from_str(json).unwrap();
assert_eq!(state.dashboard_mode, "piped");
}
#[test]
fn test_load_no_file_returns_default_values() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let state = mgr.load().unwrap();
assert_eq!(state.project_name, "");
assert_eq!(state.version, "1.1.0");
assert_eq!(state.task_summary.total, 0);
assert_eq!(state.task_summary.pending, 0);
assert_eq!(state.task_summary.in_progress, 0);
assert_eq!(state.task_summary.completed, 0);
assert_eq!(state.task_summary.failed, 0);
assert_eq!(state.task_summary.blocked, 0);
assert!(state.active_locks.is_empty());
assert!(state.agent_auth.is_empty());
assert!(state.agent_permissions.is_empty());
assert!(state.tools.is_empty());
assert_eq!(state.brain.provider, "rule-based");
assert!(state.brain.model.is_none());
assert_eq!(state.dashboard_mode, "piped");
}
#[test]
fn test_save_load_roundtrip_all_fields() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state.project_name = "roundtrip-project".to_string();
state.version = "2.0.0".to_string();
state.task_summary = TaskSummary {
total: 10,
pending: 3,
in_progress: 2,
completed: 4,
failed: 1,
blocked: 0,
};
state.brain = BrainConfig {
provider: "openai".to_string(),
model: Some("gpt-5".to_string()),
};
state.git = GitConfig {
auto_commit: false,
strategy: "worktree".to_string(),
};
state.scheduler = SchedulerConfig {
rotation: true,
pacing_min_secs: 30,
pacing_max_secs: 120,
};
state.dashboard_mode = "pty".to_string();
state
.agent_auth
.insert("claude".to_string(), "api".to_string());
state
.agent_permissions
.insert("codex".to_string(), "yolo".to_string());
mgr.save(&state).unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.project_name, "roundtrip-project");
assert_eq!(loaded.version, "2.0.0");
assert_eq!(loaded.task_summary.total, 10);
assert_eq!(loaded.task_summary.pending, 3);
assert_eq!(loaded.task_summary.in_progress, 2);
assert_eq!(loaded.task_summary.completed, 4);
assert_eq!(loaded.task_summary.failed, 1);
assert_eq!(loaded.task_summary.blocked, 0);
assert_eq!(loaded.brain.provider, "openai");
assert_eq!(loaded.brain.model.as_deref(), Some("gpt-5"));
assert!(!loaded.git.auto_commit);
assert_eq!(loaded.git.strategy, "worktree");
assert!(loaded.scheduler.rotation);
assert_eq!(loaded.scheduler.pacing_min_secs, 30);
assert_eq!(loaded.scheduler.pacing_max_secs, 120);
assert_eq!(loaded.dashboard_mode, "pty");
assert_eq!(loaded.agent_auth.get("claude").unwrap(), "api");
assert_eq!(loaded.agent_permissions.get("codex").unwrap(), "yolo");
}
#[test]
fn test_update_task_summary_persists_each_field() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let initial = ForgeState::default();
let created_at = initial.created_at;
mgr.save(&initial).unwrap();
let summary = TaskSummary {
total: 5,
pending: 2,
in_progress: 0,
completed: 3,
failed: 0,
blocked: 0,
};
mgr.update_task_summary(summary).unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.task_summary.total, 5);
assert_eq!(loaded.task_summary.pending, 2);
assert_eq!(loaded.task_summary.in_progress, 0);
assert_eq!(loaded.task_summary.completed, 3);
assert_eq!(loaded.task_summary.failed, 0);
assert_eq!(loaded.task_summary.blocked, 0);
assert!(loaded.updated_at >= created_at);
}
#[test]
fn test_update_task_summary_with_all_statuses() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
let summary = TaskSummary {
total: 15,
pending: 3,
in_progress: 4,
completed: 5,
failed: 2,
blocked: 1,
};
mgr.update_task_summary(summary).unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.task_summary.total, 15);
assert_eq!(loaded.task_summary.pending, 3);
assert_eq!(loaded.task_summary.in_progress, 4);
assert_eq!(loaded.task_summary.completed, 5);
assert_eq!(loaded.task_summary.failed, 2);
assert_eq!(loaded.task_summary.blocked, 1);
}
#[test]
fn test_lock_files_and_check_conflicts_single_task() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files(
"task-1",
AgentType::Claude,
vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
)
.unwrap();
let conflicts = mgr
.check_file_conflicts(&["src/main.rs".to_string()])
.unwrap();
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].task_id, "task-1");
assert_eq!(conflicts[0].files.len(), 2);
assert!(conflicts[0].files.contains(&"src/main.rs".to_string()));
assert!(conflicts[0].files.contains(&"src/lib.rs".to_string()));
let no_conflicts = mgr
.check_file_conflicts(&["src/other.rs".to_string()])
.unwrap();
assert_eq!(no_conflicts.len(), 0);
}
#[test]
fn test_lock_files_multiple_tasks_overlapping() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files("task-1", AgentType::Claude, vec!["src/main.rs".to_string()])
.unwrap();
mgr.lock_files(
"task-2",
AgentType::Codex,
vec!["src/main.rs".to_string(), "src/util.rs".to_string()],
)
.unwrap();
let conflicts = mgr
.check_file_conflicts(&["src/main.rs".to_string()])
.unwrap();
assert_eq!(conflicts.len(), 2);
let task_ids: Vec<&str> = conflicts.iter().map(|c| c.task_id.as_str()).collect();
assert!(task_ids.contains(&"task-1"));
assert!(task_ids.contains(&"task-2"));
let util_conflicts = mgr
.check_file_conflicts(&["src/util.rs".to_string()])
.unwrap();
assert_eq!(util_conflicts.len(), 1);
assert_eq!(util_conflicts[0].task_id, "task-2");
}
#[test]
fn test_check_conflicts_empty_when_no_locks() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
let conflicts = mgr
.check_file_conflicts(&["src/main.rs".to_string()])
.unwrap();
assert_eq!(conflicts.len(), 0);
}
#[test]
fn test_lock_files_persists_active_locks() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files(
"task-1",
AgentType::Gemini,
vec!["a.rs".to_string(), "b.rs".to_string()],
)
.unwrap();
let state = mgr.load().unwrap();
assert_eq!(state.active_locks.len(), 1);
let lock = state.active_locks.get("task-1").unwrap();
assert_eq!(lock.task_id, "task-1");
assert_eq!(lock.files.len(), 2);
assert!(lock.files.contains(&"a.rs".to_string()));
assert!(lock.files.contains(&"b.rs".to_string()));
}
#[test]
fn test_unlock_files_removes_lock() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files("task-1", AgentType::Claude, vec!["src/main.rs".to_string()])
.unwrap();
let state = mgr.load().unwrap();
assert_eq!(state.active_locks.len(), 1);
mgr.unlock_files("task-1").unwrap();
let state = mgr.load().unwrap();
assert!(state.active_locks.is_empty());
}
#[test]
fn test_unlock_files_conflicts_empty_after() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files("task-1", AgentType::Claude, vec!["src/main.rs".to_string()])
.unwrap();
let conflicts = mgr
.check_file_conflicts(&["src/main.rs".to_string()])
.unwrap();
assert_eq!(conflicts.len(), 1);
mgr.unlock_files("task-1").unwrap();
let conflicts = mgr
.check_file_conflicts(&["src/main.rs".to_string()])
.unwrap();
assert_eq!(conflicts.len(), 0);
}
#[test]
fn test_unlock_only_removes_specified_task() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files("task-1", AgentType::Claude, vec!["src/main.rs".to_string()])
.unwrap();
mgr.lock_files("task-2", AgentType::Codex, vec!["src/main.rs".to_string()])
.unwrap();
mgr.unlock_files("task-1").unwrap();
let state = mgr.load().unwrap();
assert_eq!(state.active_locks.len(), 1);
assert!(state.active_locks.contains_key("task-2"));
assert!(!state.active_locks.contains_key("task-1"));
}
#[test]
fn test_get_agent_auth_default_is_subscription() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
let auth = mgr.get_agent_auth("claude").unwrap();
assert_eq!(auth, "subscription");
}
#[test]
fn test_get_agent_auth_explicit_api_mode() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state
.agent_auth
.insert("claude".to_string(), "api".to_string());
mgr.save(&state).unwrap();
let auth = mgr.get_agent_auth("claude").unwrap();
assert_eq!(auth, "api");
}
#[test]
fn test_get_agent_auth_unknown_agent_returns_subscription() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state
.agent_auth
.insert("claude".to_string(), "api".to_string());
mgr.save(&state).unwrap();
let auth = mgr.get_agent_auth("gemini").unwrap();
assert_eq!(auth, "subscription");
}
#[test]
fn test_get_agent_permissions_default_is_safe() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
let perms = mgr.get_agent_permissions("claude").unwrap();
assert_eq!(perms, "safe");
}
#[test]
fn test_get_agent_permissions_explicit_yolo() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state
.agent_permissions
.insert("claude".to_string(), "yolo".to_string());
mgr.save(&state).unwrap();
let perms = mgr.get_agent_permissions("claude").unwrap();
assert_eq!(perms, "yolo");
}
#[test]
fn test_get_agent_permissions_unknown_agent_returns_safe() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state
.agent_permissions
.insert("claude".to_string(), "yolo".to_string());
mgr.save(&state).unwrap();
let perms = mgr.get_agent_permissions("codex").unwrap();
assert_eq!(perms, "safe");
}
#[test]
fn test_scheduler_config_default_values() {
let config = SchedulerConfig::default();
assert_eq!(config.rotation, false);
assert_eq!(config.pacing_min_secs, 64);
assert_eq!(config.pacing_max_secs, 179);
}
#[test]
fn test_refresh_task_summary_counts_from_disk() {
let tmp = tempfile::tempdir().unwrap();
let forge_dir = tmp.path().to_path_buf();
let mgr = StateManager::new(&forge_dir);
mgr.save(&ForgeState::default()).unwrap();
let tasks_dir = forge_dir.join("tasks");
std::fs::create_dir_all(&tasks_dir).unwrap();
let mut t1 = Task::new("T-001", "Pending task", "desc");
t1.status = TaskStatus::Pending;
t1.write_json(&forge_dir).unwrap();
let mut t2 = Task::new("T-002", "Completed task", "desc");
t2.status = TaskStatus::Completed;
t2.write_json(&forge_dir).unwrap();
let mut t3 = Task::new("T-003", "Failed task", "desc");
t3.status = TaskStatus::Failed;
t3.write_json(&forge_dir).unwrap();
let mut t4 = Task::new("T-004", "In-progress task", "desc");
t4.status = TaskStatus::InProgress;
t4.write_json(&forge_dir).unwrap();
let mut t5 = Task::new("T-005", "Assigned task", "desc");
t5.status = TaskStatus::Assigned;
t5.write_json(&forge_dir).unwrap();
let mut t6 = Task::new("T-006", "Blocked task", "desc");
t6.status = TaskStatus::Blocked;
t6.write_json(&forge_dir).unwrap();
mgr.refresh_task_summary().unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.task_summary.total, 6);
assert_eq!(loaded.task_summary.pending, 1);
assert_eq!(loaded.task_summary.in_progress, 2);
assert_eq!(loaded.task_summary.completed, 1);
assert_eq!(loaded.task_summary.failed, 1);
assert_eq!(loaded.task_summary.blocked, 1);
}
#[test]
fn test_refresh_task_summary_empty_tasks_dir() {
let tmp = tempfile::tempdir().unwrap();
let forge_dir = tmp.path().to_path_buf();
let mgr = StateManager::new(&forge_dir);
mgr.save(&ForgeState::default()).unwrap();
mgr.refresh_task_summary().unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.task_summary.total, 0);
assert_eq!(loaded.task_summary.pending, 0);
assert_eq!(loaded.task_summary.in_progress, 0);
assert_eq!(loaded.task_summary.completed, 0);
assert_eq!(loaded.task_summary.failed, 0);
assert_eq!(loaded.task_summary.blocked, 0);
}
#[test]
fn test_update_tools_persists_entries() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
let tools = vec![
DetectedTool {
name: "cargo".to_string(),
agent_type: AgentType::Any,
version: Some("1.79.0".to_string()),
path: "/usr/bin/cargo".to_string(),
available: true,
},
DetectedTool {
name: "npm".to_string(),
agent_type: AgentType::Any,
version: None,
path: "/usr/bin/npm".to_string(),
available: false,
},
];
mgr.update_tools(&tools).unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.tools.len(), 2);
assert_eq!(loaded.tools[0].name, "cargo");
assert_eq!(loaded.tools[0].version.as_deref(), Some("1.79.0"));
assert_eq!(loaded.tools[0].path, "/usr/bin/cargo");
assert!(loaded.tools[0].available);
assert_eq!(loaded.tools[1].name, "npm");
assert!(loaded.tools[1].version.is_none());
assert_eq!(loaded.tools[1].path, "/usr/bin/npm");
assert!(!loaded.tools[1].available);
}
#[test]
fn test_update_tools_replaces_previous() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
let tools_v1 = vec![DetectedTool {
name: "cargo".to_string(),
agent_type: AgentType::Any,
version: Some("1.79.0".to_string()),
path: "/usr/bin/cargo".to_string(),
available: true,
}];
mgr.update_tools(&tools_v1).unwrap();
assert_eq!(mgr.load().unwrap().tools.len(), 1);
let tools_v2 = vec![
DetectedTool {
name: "rustc".to_string(),
agent_type: AgentType::Claude,
version: Some("1.80.0".to_string()),
path: "/usr/bin/rustc".to_string(),
available: true,
},
DetectedTool {
name: "python3".to_string(),
agent_type: AgentType::Any,
version: Some("3.12".to_string()),
path: "/usr/bin/python3".to_string(),
available: true,
},
];
mgr.update_tools(&tools_v2).unwrap();
let loaded = mgr.load().unwrap();
assert_eq!(loaded.tools.len(), 2);
assert_eq!(loaded.tools[0].name, "rustc");
assert_eq!(loaded.tools[1].name, "python3");
}
#[test]
fn test_forge_state_default_complete() {
let state = ForgeState::default();
assert_eq!(state.version, "1.1.0");
assert_eq!(state.project_name, "");
assert!(state.tools.is_empty());
assert_eq!(state.brain.provider, "rule-based");
assert!(state.brain.model.is_none());
assert_eq!(state.task_summary.total, 0);
assert_eq!(state.task_summary.pending, 0);
assert_eq!(state.task_summary.in_progress, 0);
assert_eq!(state.task_summary.completed, 0);
assert_eq!(state.task_summary.failed, 0);
assert_eq!(state.task_summary.blocked, 0);
assert!(state.active_locks.is_empty());
assert!(state.agent_auth.is_empty());
assert!(state.agent_permissions.is_empty());
assert!(state.git.auto_commit);
assert_eq!(state.git.strategy, "single");
assert_eq!(state.scheduler.rotation, false);
assert_eq!(state.scheduler.pacing_min_secs, 64);
assert_eq!(state.scheduler.pacing_max_secs, 179);
assert_eq!(state.dashboard_mode, "piped");
assert_eq!(state.created_at, state.updated_at);
}
#[test]
fn test_task_summary_default_all_zeroes() {
let summary = TaskSummary::default();
assert_eq!(summary.total, 0);
assert_eq!(summary.pending, 0);
assert_eq!(summary.in_progress, 0);
assert_eq!(summary.completed, 0);
assert_eq!(summary.failed, 0);
assert_eq!(summary.blocked, 0);
}
#[test]
fn test_lock_same_task_twice_replaces_files() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files("task-1", AgentType::Claude, vec!["a.rs".to_string()])
.unwrap();
mgr.lock_files(
"task-1",
AgentType::Claude,
vec!["b.rs".to_string(), "c.rs".to_string()],
)
.unwrap();
let state = mgr.load().unwrap();
assert_eq!(state.active_locks.len(), 1);
let lock = state.active_locks.get("task-1").unwrap();
assert_eq!(lock.files.len(), 2);
assert!(lock.files.contains(&"b.rs".to_string()));
assert!(lock.files.contains(&"c.rs".to_string()));
assert!(!lock.files.contains(&"a.rs".to_string()));
}
#[test]
fn test_unlock_nonexistent_task_is_noop() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
mgr.save(&ForgeState::default()).unwrap();
mgr.lock_files("task-1", AgentType::Claude, vec!["a.rs".to_string()])
.unwrap();
mgr.unlock_files("task-999").unwrap();
let state = mgr.load().unwrap();
assert_eq!(state.active_locks.len(), 1);
assert!(state.active_locks.contains_key("task-1"));
}
#[test]
fn test_multiple_agents_auth_and_permissions() {
let tmp = tempfile::tempdir().unwrap();
let mgr = StateManager::new(tmp.path());
let mut state = ForgeState::default();
state
.agent_auth
.insert("claude".to_string(), "api".to_string());
state
.agent_auth
.insert("codex".to_string(), "subscription".to_string());
state
.agent_permissions
.insert("claude".to_string(), "yolo".to_string());
state
.agent_permissions
.insert("gemini".to_string(), "safe".to_string());
mgr.save(&state).unwrap();
assert_eq!(mgr.get_agent_auth("claude").unwrap(), "api");
assert_eq!(mgr.get_agent_auth("codex").unwrap(), "subscription");
assert_eq!(mgr.get_agent_auth("unknown").unwrap(), "subscription");
assert_eq!(mgr.get_agent_permissions("claude").unwrap(), "yolo");
assert_eq!(mgr.get_agent_permissions("gemini").unwrap(), "safe");
assert_eq!(mgr.get_agent_permissions("unknown").unwrap(), "safe");
}
}