Skip to main content

oxicode/store/
todo_state.rs

1//! Session-scoped todo state.
2//!
3//! `oxicode_agent::tools::TodoStateProvider`의 구현. 에이전트의 `todo` 도구와
4//! TUI sticky panel 사이의 단일 진실 소스. `Arc<RwLock<Vec<TodoPhase>>>`로
5//! 보유하고, clone은 cheap (Arc 공유).
6
7use std::pin::Pin;
8use std::sync::Arc;
9
10use oxicode_agent::tools::todo::{TodoOp, TodoPhase, TodoUpdateResult};
11use oxicode_agent::tools::{TodoStateProvider, ToolError};
12use parking_lot::RwLock;
13
14/// Session 단위 todo 상태.
15///
16/// `TodoStateProvider` 특성 구현 — `todo` 도구와 sticky panel이 공유.
17/// TUI 채널로 갱신 알림을 보내려면 `notifier: Option<Box<dyn Fn>>`를 추가.
18#[derive(Debug)]
19pub struct TodoState {
20    phases: RwLock<Vec<TodoPhase>>,
21}
22
23impl TodoState {
24    /// 새 빈 상태 생성.
25    pub fn new() -> Self {
26        Self {
27            phases: RwLock::new(Vec::new()),
28        }
29    }
30
31    /// 초기 phase들을 지정하여 생성.
32    pub fn with_phases(phases: Vec<TodoPhase>) -> Self {
33        Self {
34            phases: RwLock::new(phases),
35        }
36    }
37
38    /// 현재 phase 스냅샷 (TUI 매 프레임 호출).
39    pub fn get_phases(&self) -> Vec<TodoPhase> {
40        self.phases.read().clone()
41    }
42
43    /// ops 적용. `apply_ops` 헬퍼를 위임.
44    pub fn apply(&self, ops: Vec<TodoOp>) -> TodoUpdateResult {
45        let mut phases = self.phases.write();
46        let result = oxicode_agent::tools::todo::apply_ops(&mut phases, &ops);
47        // 가드는 여기서 drop — async 호출 전.
48        drop(phases);
49        result
50    }
51
52    /// 전체 삭제 (테스트/리셋용).
53    #[allow(dead_code)]
54    pub fn clear(&self) {
55        self.phases.write().clear();
56    }
57}
58
59impl Default for TodoState {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl TodoStateProvider for TodoState {
66    fn get_phases(&self) -> Vec<TodoPhase> {
67        TodoState::get_phases(self)
68    }
69
70    fn apply_ops<'a>(
71        &'a self,
72        ops: Vec<TodoOp>,
73    ) -> Pin<Box<dyn Future<Output = Result<TodoUpdateResult, ToolError>> + Send + 'a>> {
74        Box::pin(async move {
75            // RwLock 가드는 동기 acquire → await 전에 drop
76            let mut phases = self.phases.write();
77            Ok(oxicode_agent::tools::todo::apply_ops(&mut phases, &ops))
78        })
79    }
80}
81
82/// `Arc<TodoState>`를 그대로 `Arc<dyn TodoStateProvider>`로 변환.
83pub fn provider_from_state(state: Arc<TodoState>) -> Arc<dyn TodoStateProvider> {
84    state as Arc<dyn TodoStateProvider>
85}
86
87// ── Tests ────────────────────────────────────────────────────────────
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use oxicode_agent::tools::todo::TodoItem;
93
94    #[test]
95    fn empty_state() {
96        let state = TodoState::new();
97        assert!(state.get_phases().is_empty());
98    }
99
100    #[test]
101    fn apply_ops_via_provider() {
102        let state = TodoState::new();
103        let provider: Arc<dyn TodoStateProvider> = provider_from_state(Arc::new(state));
104
105        // tokio runtime 없이 apply_ops 동기 부분만 검증.
106        let phases = provider.get_phases();
107        assert!(phases.is_empty());
108
109        // ops 적용 후 get_phases가 갱신됨을 검증.
110        // (비동기 trait method 호출은 tokio test 필요 — 간단히 동기 .apply() 사용)
111        let state_arc = Arc::new(TodoState::new());
112        let result = state_arc.apply(vec![TodoOp::Init {
113            list: None,
114            items: Some(vec!["task1".into(), "task2".into()]),
115        }]);
116        assert!(result.errors.is_empty());
117        let phases = state_arc.get_phases();
118        assert_eq!(phases.len(), 1);
119        assert_eq!(phases[0].tasks.len(), 2);
120    }
121
122    #[test]
123    fn provider_share_state() {
124        // 같은 Arc를 공유하는 두 provider가 같은 상태를 본다.
125        let state = Arc::new(TodoState::with_phases(vec![]));
126        let p1: Arc<dyn TodoStateProvider> = state.clone();
127        let p2: Arc<dyn TodoStateProvider> = state.clone();
128
129        let _ = p1.get_phases(); // Arc::strong_count 증가 확인은 보이지 않지만 호출 가능
130        let _ = p2.get_phases();
131        assert!(state.get_phases().is_empty());
132    }
133
134    #[test]
135    fn with_phases_initial() {
136        let initial = vec![TodoPhase {
137            name: "P".into(),
138            tasks: vec![TodoItem {
139                content: "t".into(),
140                status: oxicode_agent::tools::todo::TodoStatus::Pending,
141                notes: None,
142                block_reason: None,
143            }],
144        }];
145        let state = TodoState::with_phases(initial);
146        assert_eq!(state.get_phases().len(), 1);
147        assert_eq!(state.get_phases()[0].name, "P");
148    }
149}