Skip to main content

ito_core/
state.rs

1//! State management operations for `planning/STATE.md`.
2//!
3//! Wraps the pure domain functions in `ito_domain::state` with filesystem I/O.
4
5use crate::errors::{CoreError, CoreResult};
6use std::path::Path;
7
8/// Actions that can be performed on STATE.md.
9pub enum StateAction {
10    /// Record a decision with associated text.
11    AddDecision {
12        /// The decision text to record.
13        text: String,
14    },
15    /// Record a blocker with associated text.
16    AddBlocker {
17        /// The blocker text to record.
18        text: String,
19    },
20    /// Record a timestamped note with associated text.
21    AddNote {
22        /// The note text to record.
23        text: String,
24    },
25    /// Update the current focus area.
26    SetFocus {
27        /// The focus text to set.
28        text: String,
29    },
30    /// Record an open question.
31    AddQuestion {
32        /// The question text to record.
33        text: String,
34    },
35}
36
37/// Read the contents of `planning/STATE.md`.
38pub fn read_state(ito_path: &Path) -> CoreResult<String> {
39    let state_path = ito_path.join("planning").join("STATE.md");
40    ito_common::io::read_to_string(&state_path)
41        .map_err(|e| CoreError::io("reading STATE.md", std::io::Error::other(e)))
42}
43
44/// Apply a state action to `planning/STATE.md` and write it back.
45pub fn update_state(ito_path: &Path, action: StateAction) -> CoreResult<()> {
46    let state_path = ito_path.join("planning").join("STATE.md");
47    let contents = ito_common::io::read_to_string(&state_path)
48        .map_err(|e| CoreError::io("reading STATE.md", std::io::Error::other(e)))?;
49    let date = crate::time::now_date();
50
51    let updated = match action {
52        StateAction::AddDecision { ref text } => {
53            ito_domain::state::add_decision(&contents, &date, text)
54        }
55        StateAction::AddBlocker { ref text } => {
56            ito_domain::state::add_blocker(&contents, &date, text)
57        }
58        StateAction::AddNote { ref text } => {
59            let time = crate::time::now_time();
60            ito_domain::state::add_note(&contents, &date, &time, text)
61        }
62        StateAction::SetFocus { ref text } => ito_domain::state::set_focus(&contents, &date, text),
63        StateAction::AddQuestion { ref text } => {
64            ito_domain::state::add_question(&contents, &date, text)
65        }
66    };
67
68    let updated = updated.map_err(CoreError::validation)?;
69
70    ito_common::io::write(&state_path, updated.as_bytes())
71        .map_err(|e| CoreError::io("writing STATE.md", std::io::Error::other(e)))
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    /// Helper: write a file creating parent directories as needed.
79    fn write_file(path: &std::path::Path, contents: &str) {
80        if let Some(parent) = path.parent() {
81            std::fs::create_dir_all(parent).expect("create parent dirs");
82        }
83        std::fs::write(path, contents).expect("write fixture");
84    }
85
86    /// Minimal STATE.md that has every section the domain functions expect.
87    fn minimal_state_md(date: &str) -> String {
88        format!(
89            "# Project State\n\n\
90             Last Updated: {date}\n\n\
91             ## Current Focus\n\
92             [placeholder]\n\n\
93             ## Recent Decisions\n\
94             - {date}: Project initialized\n\n\
95             ## Open Questions\n\
96             - [ ] placeholder\n\n\
97             ## Blockers\n\
98             [None currently]\n\n\
99             ## Session Notes\n\
100             ### {date} - Initial Setup\n\
101             - Completed: init\n"
102        )
103    }
104
105    #[test]
106    fn read_state_returns_error_for_missing_file() {
107        let tmp = tempfile::tempdir().expect("tempdir");
108        let ito_path = tmp.path();
109        // Do NOT create planning/STATE.md
110
111        let result = read_state(ito_path);
112        assert!(result.is_err(), "read_state should fail for missing file");
113        let err = result.unwrap_err();
114        let msg = err.to_string();
115        assert!(
116            msg.contains("STATE.md"),
117            "error should mention STATE.md, got: {msg}"
118        );
119    }
120
121    #[test]
122    fn read_state_returns_contents_for_existing_file() {
123        let tmp = tempfile::tempdir().expect("tempdir");
124        let ito_path = tmp.path();
125        let contents = minimal_state_md("2025-01-01");
126        write_file(&ito_path.join("planning").join("STATE.md"), &contents);
127
128        let result = read_state(ito_path).expect("read_state should succeed");
129        assert_eq!(result, contents);
130    }
131
132    #[test]
133    fn update_state_add_decision() {
134        let tmp = tempfile::tempdir().expect("tempdir");
135        let ito_path = tmp.path();
136        write_file(
137            &ito_path.join("planning").join("STATE.md"),
138            &minimal_state_md("2025-01-01"),
139        );
140
141        update_state(
142            ito_path,
143            StateAction::AddDecision {
144                text: "Use Rust".to_string(),
145            },
146        )
147        .expect("add decision should succeed");
148
149        let updated =
150            std::fs::read_to_string(ito_path.join("planning").join("STATE.md")).expect("read back");
151        assert!(
152            updated.contains("Use Rust"),
153            "decision text should appear in STATE.md"
154        );
155    }
156
157    #[test]
158    fn update_state_add_blocker() {
159        let tmp = tempfile::tempdir().expect("tempdir");
160        let ito_path = tmp.path();
161        write_file(
162            &ito_path.join("planning").join("STATE.md"),
163            &minimal_state_md("2025-01-01"),
164        );
165
166        update_state(
167            ito_path,
168            StateAction::AddBlocker {
169                text: "Waiting on API access".to_string(),
170            },
171        )
172        .expect("add blocker should succeed");
173
174        let updated =
175            std::fs::read_to_string(ito_path.join("planning").join("STATE.md")).expect("read back");
176        assert!(
177            updated.contains("Waiting on API access"),
178            "blocker text should appear in STATE.md"
179        );
180        // The "[None currently]" placeholder should have been replaced
181        assert!(
182            !updated.contains("[None currently]"),
183            "placeholder should be replaced"
184        );
185    }
186
187    #[test]
188    fn update_state_set_focus() {
189        let tmp = tempfile::tempdir().expect("tempdir");
190        let ito_path = tmp.path();
191        write_file(
192            &ito_path.join("planning").join("STATE.md"),
193            &minimal_state_md("2025-01-01"),
194        );
195
196        update_state(
197            ito_path,
198            StateAction::SetFocus {
199                text: "Implement auth module".to_string(),
200            },
201        )
202        .expect("set focus should succeed");
203
204        let updated =
205            std::fs::read_to_string(ito_path.join("planning").join("STATE.md")).expect("read back");
206        assert!(
207            updated.contains("Implement auth module"),
208            "focus text should appear in STATE.md"
209        );
210    }
211
212    #[test]
213    fn update_state_add_question() {
214        let tmp = tempfile::tempdir().expect("tempdir");
215        let ito_path = tmp.path();
216        write_file(
217            &ito_path.join("planning").join("STATE.md"),
218            &minimal_state_md("2025-01-01"),
219        );
220
221        update_state(
222            ito_path,
223            StateAction::AddQuestion {
224                text: "Should we use gRPC?".to_string(),
225            },
226        )
227        .expect("add question should succeed");
228
229        let updated =
230            std::fs::read_to_string(ito_path.join("planning").join("STATE.md")).expect("read back");
231        assert!(
232            updated.contains("Should we use gRPC?"),
233            "question text should appear in STATE.md"
234        );
235    }
236
237    #[test]
238    fn update_state_add_note() {
239        let tmp = tempfile::tempdir().expect("tempdir");
240        let ito_path = tmp.path();
241        write_file(
242            &ito_path.join("planning").join("STATE.md"),
243            &minimal_state_md("2025-01-01"),
244        );
245
246        update_state(
247            ito_path,
248            StateAction::AddNote {
249                text: "Reviewed the design".to_string(),
250            },
251        )
252        .expect("add note should succeed");
253
254        let updated =
255            std::fs::read_to_string(ito_path.join("planning").join("STATE.md")).expect("read back");
256        assert!(
257            updated.contains("Reviewed the design"),
258            "note text should appear in STATE.md"
259        );
260    }
261
262    #[test]
263    fn update_state_returns_error_for_missing_file() {
264        let tmp = tempfile::tempdir().expect("tempdir");
265        let ito_path = tmp.path();
266        // Do NOT create planning/STATE.md
267
268        let result = update_state(
269            ito_path,
270            StateAction::AddDecision {
271                text: "test".to_string(),
272            },
273        );
274        assert!(result.is_err(), "update_state should fail for missing file");
275    }
276}