ito_domain/
state.rs

1//! Helpers for updating `planning/STATE.md`.
2//!
3//! These functions implement small, targeted **pure** edits (e.g. updating the
4//! last updated date or inserting new bullets) while keeping the rest of the
5//! file intact.
6//!
7//! Clock helpers (`now_time`, `now_date`) live in `ito-core::time`.
8
9use regex::Regex;
10
11/// Update the `Last Updated:` line in a STATE.md document.
12pub fn update_last_updated(contents: &str, date: &str) -> String {
13    let re = Regex::new(r"(?m)^Last Updated: .+$").unwrap();
14    if re.is_match(contents) {
15        return re
16            .replace_all(contents, format!("Last Updated: {date}").as_str())
17            .to_string();
18    }
19    contents.to_string()
20}
21
22fn insert_after_heading(
23    contents: &str,
24    heading: &str,
25    line_to_insert: &str,
26) -> Result<String, String> {
27    let mut out: Vec<String> = Vec::new();
28    let mut inserted = false;
29    for line in contents.lines() {
30        out.push(line.to_string());
31        if !inserted && line.trim() == heading {
32            out.push(line_to_insert.to_string());
33            inserted = true;
34        }
35    }
36    if !inserted {
37        return Err(format!("Missing heading: {heading}"));
38    }
39    let mut s = out.join("\n");
40    s.push('\n');
41    Ok(s)
42}
43
44/// Add a decision entry under "Recent Decisions" and update the last-updated date.
45pub fn add_decision(contents: &str, date: &str, text: &str) -> Result<String, String> {
46    let line = format!("- {date}: {text}");
47    let updated = insert_after_heading(contents, "## Recent Decisions", &line)?;
48    Ok(update_last_updated(&updated, date))
49}
50
51/// Add a question entry under "Open Questions" and update the last-updated date.
52pub fn add_question(contents: &str, date: &str, text: &str) -> Result<String, String> {
53    let line = format!("- [ ] {text}");
54    let updated = insert_after_heading(contents, "## Open Questions", &line)?;
55    Ok(update_last_updated(&updated, date))
56}
57
58/// Add a blocker entry under "Blockers" and update the last-updated date.
59pub fn add_blocker(contents: &str, date: &str, text: &str) -> Result<String, String> {
60    let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
61    let mut i = 0usize;
62    let mut in_blockers = false;
63    let mut inserted = false;
64    while i < lines.len() {
65        let line = lines[i].as_str();
66        if line.trim() == "## Blockers" {
67            in_blockers = true;
68            i += 1;
69            continue;
70        }
71        if in_blockers {
72            if line.starts_with("## ") {
73                lines.insert(i, format!("- {text}"));
74                inserted = true;
75                break;
76            }
77            if line.trim() == "[None currently]" {
78                lines[i] = format!("- {text}");
79                inserted = true;
80                break;
81            }
82            if line.trim().is_empty() {
83                lines.insert(i, format!("- {text}"));
84                inserted = true;
85                break;
86            }
87        }
88        i += 1;
89    }
90    if !inserted {
91        return Err("Could not find Blockers section".to_string());
92    }
93    let mut out = lines.join("\n");
94    out.push('\n');
95    Ok(update_last_updated(&out, date))
96}
97
98/// Set the "Current Focus" section to `text` and update the last-updated date.
99pub fn set_focus(contents: &str, date: &str, text: &str) -> Result<String, String> {
100    // Match TS: /(## Current Focus\n)([^\n#]*)/
101    let re = Regex::new(r"(?m)(## Current Focus\n)([^\n#]*)").unwrap();
102    if !re.is_match(contents) {
103        return Err("Could not find Current Focus section".to_string());
104    }
105    let updated = re
106        .replace(contents, |caps: &regex::Captures<'_>| {
107            format!("{}{}\n", &caps[1], text)
108        })
109        .to_string();
110    Ok(update_last_updated(&updated, date))
111}
112
113/// Add a timestamped note under "Session Notes".
114///
115/// If a matching session header exists, the note is inserted immediately after it.
116pub fn add_note(contents: &str, date: &str, time: &str, text: &str) -> Result<String, String> {
117    let session_header = format!("### {date} Session");
118    let entry = format!("- {time}: {text}");
119
120    let mut lines: Vec<String> = contents.lines().map(|l| l.to_string()).collect();
121
122    // If a matching session header exists, insert immediately after it.
123    for i in 0..lines.len() {
124        if lines[i].trim() == session_header {
125            lines.insert(i + 1, entry);
126            let mut out = lines.join("\n");
127            out.push('\n');
128            return Ok(update_last_updated(&out, date));
129        }
130    }
131
132    // Otherwise, insert after Session Notes heading.
133    for i in 0..lines.len() {
134        if lines[i].trim() == "## Session Notes" {
135            lines.insert(i + 1, session_header);
136            lines.insert(i + 2, entry);
137            let mut out = lines.join("\n");
138            out.push('\n');
139            return Ok(update_last_updated(&out, date));
140        }
141    }
142
143    Err("Could not find Session Notes section".to_string())
144}
145
146// NOTE: `now_time()` and `now_date()` were moved to `ito_core::time` to
147// keep the domain layer free of non-deterministic I/O.