新增: 项目状态机分层+任务状态枚举合并
真分层(df-project): - ProjectManager 加 can_transition/transition/is_terminal 状态机 - 创建加 EmptyName 校验,返回 Result 而非裸实体 - 新增 ProjectTransitionError 三种错误变体 - 调用方(idea.rs promote_idea)适配 Result 传播 双源合并: - TaskStatus::as_str 改为 const fn(允许 const 上下文调用) - task_state_machine 字符串常量从 TaskStatus enum 派生 - IdeaStatus/ProjectStatus as_str 同步改为 const fn(一致性) 架构债设计方案: - #8 IPC 错误结构化迁移路径(IpcError enum + 前端消费) - #15 AI 状态机抽离 df-ai-session(4 阶段) - #18 配置四源统一(AppConfig 单例 + 环境变量覆盖) - #19 SQLite 连接池(r2d2)
This commit is contained in:
@@ -22,27 +22,29 @@
|
||||
//! - cancelled → 终态
|
||||
|
||||
// ============================================================
|
||||
// 状态字符串常量 — 与 df-types::TaskStatus::as_str 一一对应
|
||||
// 状态字符串常量 — 从 df-types::TaskStatus::as_str 派生(单一真相源)
|
||||
// ============================================================
|
||||
//
|
||||
// 不复用 df-types::TaskStatus enum(独立模块定位 + 避免推进链判定耦合存储枚举类型),
|
||||
// 但字符串值严格对齐(df-types::TaskStatus::as_str 产出的小写 snake_case),
|
||||
// 保证状态机判定的 from/to 与数据库 status 列存值语义一致。
|
||||
// 任务 #17 合并:字符串常量不再独立定义,直接从 `TaskStatus::as_str()` 派生。
|
||||
// 消除 df-nodes 字符串常量与 df-types enum 之间的双源问题—— 任一处修改 enum 的
|
||||
// as_str 输出,本模块常量自动同步,编译期即可发现柡移。
|
||||
|
||||
use df_types::types::TaskStatus;
|
||||
|
||||
/// 待开始
|
||||
pub const TODO: &str = "todo";
|
||||
pub const TODO: &str = TaskStatus::Todo.as_str();
|
||||
/// 进行中
|
||||
pub const IN_PROGRESS: &str = "in_progress";
|
||||
pub const IN_PROGRESS: &str = TaskStatus::InProgress.as_str();
|
||||
/// 代码审查中
|
||||
pub const IN_REVIEW: &str = "in_review";
|
||||
pub const IN_REVIEW: &str = TaskStatus::InReview.as_str();
|
||||
/// 测试中
|
||||
pub const TESTING: &str = "testing";
|
||||
pub const TESTING: &str = TaskStatus::Testing.as_str();
|
||||
/// 已完成(终态)
|
||||
pub const DONE: &str = "done";
|
||||
pub const DONE: &str = TaskStatus::Done.as_str();
|
||||
/// 已阻塞
|
||||
pub const BLOCKED: &str = "blocked";
|
||||
pub const BLOCKED: &str = TaskStatus::Blocked.as_str();
|
||||
/// 已取消(终态)
|
||||
pub const CANCELLED: &str = "cancelled";
|
||||
pub const CANCELLED: &str = TaskStatus::Cancelled.as_str();
|
||||
|
||||
/// 全部合法状态值(供输入校验与错误提示复用)
|
||||
pub const ALL_STATES: &[&str] = &[
|
||||
|
||||
@@ -11,3 +11,4 @@ tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! 项目管理器 — 项目的 CRUD 与生命周期管理
|
||||
//! 项目管理器 — 项目的领域层(CRUD 构造 + 状态机 + 业务约束)
|
||||
//!
|
||||
//! 任务 #16 真分层:本 crate 不再只是"构造实体的工厂函数",而是承载项目领域规则。
|
||||
//! Storage 层(df-storage::ProjectRepo)仅负责持久化,状态合法性 / 业务约束在此。
|
||||
//!
|
||||
//! 调用方(src-tauri commands/project.rs)推进项目状态时必须经
|
||||
//! `ProjectManager::can_transition` / `transition` 校验,防非法跳态。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -39,15 +45,32 @@ pub struct CreateProjectInput {
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
/// 项目管理器
|
||||
/// 项目状态机错误
|
||||
#[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) -> Project {
|
||||
pub fn create(input: CreateProjectInput) -> Result<Project, ProjectTransitionError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(ProjectTransitionError::EmptyName);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
Project {
|
||||
Ok(Project {
|
||||
id: df_types::types::new_id(),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -57,11 +80,15 @@ impl ProjectManager {
|
||||
tags: input.tags,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 从想法创建项目
|
||||
pub fn create_from_idea(name: String, description: String, idea_id: IdeaId) -> Project {
|
||||
pub fn create_from_idea(
|
||||
name: String,
|
||||
description: String,
|
||||
idea_id: IdeaId,
|
||||
) -> Result<Project, ProjectTransitionError> {
|
||||
Self::create(CreateProjectInput {
|
||||
name,
|
||||
description,
|
||||
@@ -71,4 +98,118 @@ impl ProjectManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// 判断项目状态转换是否合法(状态机核心)。
|
||||
///
|
||||
/// 项目状态语义(与 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<ProjectStatus, ProjectTransitionError> {
|
||||
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(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ pub enum IdeaStatus {
|
||||
|
||||
impl IdeaStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
///
|
||||
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
IdeaStatus::Draft => "draft",
|
||||
IdeaStatus::PendingReview => "pending_review",
|
||||
@@ -128,7 +130,9 @@ pub enum ProjectStatus {
|
||||
|
||||
impl ProjectStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
///
|
||||
/// `const fn`(与 TaskStatus::as_str 对齐,允许调用方在 const 上下文使用)。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ProjectStatus::Planning => "planning",
|
||||
ProjectStatus::InProgress => "in_progress",
|
||||
@@ -186,7 +190,10 @@ pub enum TaskStatus {
|
||||
|
||||
impl TaskStatus {
|
||||
/// 返回数据库存储用的小写字符串
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
///
|
||||
/// `const fn` 使调用方可在 `const` 上下文调用(任务 #17 合并:df-nodes 的
|
||||
/// `task_state_machine` 字符串常量从本方法派生,消除双源柡移)。
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TaskStatus::Todo => "todo",
|
||||
TaskStatus::InProgress => "in_progress",
|
||||
|
||||
Reference in New Issue
Block a user