//! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束) //! //! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。 //! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。 //! //! 调用方(src-tauri commands/project.rs)推进项目状态时必须经 //! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。 use serde::{Deserialize, Serialize}; use df_types::types::{IdeaId, Priority, ProjectId, ProjectStatus}; /// 项目实体 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Project { /// 唯一 ID pub id: ProjectId, /// 项目名称 pub name: String, /// 项目描述 pub description: String, /// 当前状态 pub status: ProjectStatus, /// 来源想法 ID(可选,如果是从想法晋升来的) pub idea_id: Option, /// 优先级 pub priority: Priority, /// 标签 pub tags: Vec, /// 创建时间 pub created_at: chrono::DateTime, /// 更新时间 pub updated_at: chrono::DateTime, } /// 创建项目的输入 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateProjectInput { pub name: String, pub description: String, pub idea_id: Option, #[serde(default)] pub priority: Priority, #[serde(default)] pub tags: Vec, } /// 项目状态机错误 #[derive(Debug, thiserror::Error)] pub enum ProjectTransitionError { #[error("项目状态转换非法: {from:?} → {to:?}")] IllegalTransition { from: ProjectStatus, to: ProjectStatus, }, #[error("项目名称不能为空")] EmptyName, #[error("项目已处在终态 {0:?},不可再推进")] TerminalState(ProjectStatus), } /// 项目管理器(无状态纯逻辑,实体构造 + 状态机 + 业务约束) pub struct ProjectManager; impl ProjectManager { /// 创建新项目 — 构造领域实体(不落库);持久化由调用方经 storage 层 ProjectRecord 映射完成 /// (见 commands/idea.rs::promote_idea)。领域层不依赖 storage,保持分层。 pub fn create(input: CreateProjectInput) -> Result { if input.name.trim().is_empty() { return Err(ProjectTransitionError::EmptyName); } let now = chrono::Utc::now(); Ok(Project { id: df_types::types::new_id(), name: input.name, description: input.description, status: ProjectStatus::Planning, idea_id: input.idea_id, priority: input.priority, tags: input.tags, created_at: now, updated_at: now, }) } /// 从想法创建项目 pub fn create_from_idea( name: String, description: String, idea_id: IdeaId, ) -> Result { Self::create(CreateProjectInput { name, description, idea_id: Some(idea_id), priority: Priority::default(), tags: Vec::new(), }) } /// 判断项目状态转换是否合法(状态机核心)。 /// /// 项目状态语义(与 ARCHITECTURE.md 一致): /// - Planning → InProgress(开始), Cancelled(取消) /// - InProgress → Testing(提交测试), Paused(暂停), Cancelled /// - Testing → Completed(测试通过), InProgress(退回开发), Cancelled /// - Completed → 终态(不可变) /// - Paused → InProgress(恢复), Cancelled /// - Cancelled → 终态 pub fn can_transition(from: &ProjectStatus, to: &ProjectStatus) -> bool { use ProjectStatus::*; matches!((from, to), (Planning, InProgress) | (Planning, Cancelled) | (InProgress, Testing) | (InProgress, Paused) | (InProgress, Cancelled) | (Testing, Completed) | (Testing, InProgress) | (Testing, Cancelled) | (Paused, InProgress) | (Paused, Cancelled) ) } /// 执行状态转换,返回新状态或非法错误。 /// /// 调用方(如 IPC `update_project_status` / `advance_project`)应用本方法校验后 /// 再写 storage,防跳态(如 Planning → Completed 跳过 Testing)。 pub fn transition( from: ProjectStatus, to: ProjectStatus, ) -> Result { if !Self::can_transition(&from, &to) { // 区分错误: 终态→任何 vs 一般非法 if matches!(from, ProjectStatus::Completed | ProjectStatus::Cancelled) { return Err(ProjectTransitionError::TerminalState(from)); } return Err(ProjectTransitionError::IllegalTransition { from, to }); } Ok(to) } /// 是否为终态(不可再转换) pub fn is_terminal(s: &ProjectStatus) -> bool { matches!(s, ProjectStatus::Completed | ProjectStatus::Cancelled) } } #[cfg(test)] mod tests { use super::*; #[test] fn create_rejects_empty_name() { let err = ProjectManager::create(CreateProjectInput { name: " ".into(), description: String::new(), idea_id: None, priority: Priority::default(), tags: vec![], }).unwrap_err(); assert!(matches!(err, ProjectTransitionError::EmptyName)); } #[test] fn main_path_planning_to_completed() { use ProjectStatus::*; assert!(ProjectManager::can_transition(&Planning, &InProgress)); assert!(ProjectManager::can_transition(&InProgress, &Testing)); assert!(ProjectManager::can_transition(&Testing, &Completed)); } #[test] fn illegal_skips_rejected() { use ProjectStatus::*; assert!(!ProjectManager::can_transition(&Planning, &Completed)); assert!(!ProjectManager::can_transition(&Planning, &Testing)); assert!(!ProjectManager::can_transition(&InProgress, &Completed)); } #[test] fn terminal_states_block_all() { use ProjectStatus::*; for term in [Completed, Cancelled] { for to in [Planning, InProgress, Testing, Paused] { assert!(!ProjectManager::can_transition(&term, &to)); } } } #[test] fn paused_round_trip() { use ProjectStatus::*; assert!(ProjectManager::can_transition(&InProgress, &Paused)); assert!(ProjectManager::can_transition(&Paused, &InProgress)); } #[test] fn transition_returns_target_on_legal() { use ProjectStatus::*; assert_eq!( ProjectManager::transition(Planning, InProgress).unwrap(), InProgress ); } #[test] fn transition_errors_on_illegal() { use ProjectStatus::*; let err = ProjectManager::transition(Planning, Completed).unwrap_err(); assert!(matches!(err, ProjectTransitionError::IllegalTransition { .. })); } #[test] fn transition_errors_on_terminal_source() { use ProjectStatus::*; let err = ProjectManager::transition(Completed, InProgress).unwrap_err(); assert!(matches!(err, ProjectTransitionError::TerminalState(_))); } }