1use crate::tools::file_ops::hematite_dir;
2use serde_json::{json, Value};
3use std::fs;
4
5#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
6pub struct PlanHandoff {
7 pub goal: String,
8 #[serde(default)]
9 pub target_files: Vec<String>,
10 #[serde(default)]
11 pub ordered_steps: Vec<String>,
12 pub verification: String,
13 #[serde(default)]
14 pub risks: Vec<String>,
15 #[serde(default)]
16 pub open_questions: Vec<String>,
17}
18
19impl PlanHandoff {
20 pub fn has_signal(&self) -> bool {
21 !self.goal.trim().is_empty()
22 || !self.target_files.is_empty()
23 || !self.ordered_steps.is_empty()
24 || !self.verification.trim().is_empty()
25 || !self.risks.is_empty()
26 || !self.open_questions.is_empty()
27 }
28
29 pub fn summary_line(&self) -> String {
30 let goal = self.goal.trim();
31 if goal.is_empty() {
32 "Plan ready".to_string()
33 } else if goal.chars().count() > 48 {
34 let truncated: String = goal.chars().take(45).collect();
35 format!("{truncated}...")
36 } else {
37 goal.to_string()
38 }
39 }
40
41 pub fn to_prompt(&self) -> String {
42 let mut out = String::new();
43 if !self.goal.trim().is_empty() {
44 out.push_str(&format!(" - Goal: {}\n", self.goal.trim()));
45 }
46 if !self.target_files.is_empty() {
47 out.push_str(&format!(
48 " - Target Files: {}\n",
49 self.target_files.join(", ")
50 ));
51 }
52 if !self.ordered_steps.is_empty() {
53 out.push_str(" - Ordered Steps:\n");
54 for step in &self.ordered_steps {
55 out.push_str(&format!(" - {}\n", step));
56 }
57 }
58 if !self.verification.trim().is_empty() {
59 out.push_str(&format!(" - Verification: {}\n", self.verification.trim()));
60 }
61 if !self.risks.is_empty() {
62 out.push_str(" - Risks:\n");
63 for risk in &self.risks {
64 out.push_str(&format!(" - {}\n", risk));
65 }
66 }
67 if !self.open_questions.is_empty() {
68 out.push_str(" - Open Questions:\n");
69 for question in &self.open_questions {
70 out.push_str(&format!(" - {}\n", question));
71 }
72 }
73 out
74 }
75
76 pub fn to_markdown(&self) -> String {
77 let mut out = String::new();
78 out.push_str("# Goal\n");
79 out.push_str(self.goal.trim());
80 out.push_str("\n\n# Target Files\n");
81 if self.target_files.is_empty() {
82 out.push_str("- none specified");
83 } else {
84 for path in &self.target_files {
85 out.push_str(&format!("- {path}\n"));
86 }
87 if out.ends_with('\n') {
88 out.pop();
89 }
90 }
91 out.push_str("\n\n# Ordered Steps\n");
92 if self.ordered_steps.is_empty() {
93 out.push_str("1. clarify implementation steps");
94 } else {
95 for (idx, step) in self.ordered_steps.iter().enumerate() {
96 out.push_str(&format!("{}. {}\n", idx + 1, step));
97 }
98 if out.ends_with('\n') {
99 out.pop();
100 }
101 }
102 out.push_str("\n\n# Verification\n");
103 out.push_str(if self.verification.trim().is_empty() {
104 "verify_build(action: \"build\")"
105 } else {
106 self.verification.trim()
107 });
108 out.push_str("\n\n# Risks\n");
109 if self.risks.is_empty() {
110 out.push_str("- none noted");
111 } else {
112 for risk in &self.risks {
113 out.push_str(&format!("- {risk}\n"));
114 }
115 if out.ends_with('\n') {
116 out.pop();
117 }
118 }
119 out.push_str("\n\n# Open Questions\n");
120 if self.open_questions.is_empty() {
121 out.push_str("- none");
122 } else {
123 for question in &self.open_questions {
124 out.push_str(&format!("- {question}\n"));
125 }
126 if out.ends_with('\n') {
127 out.pop();
128 }
129 }
130 out.push('\n');
131 out
132 }
133}
134
135fn plan_path() -> std::path::PathBuf {
136 hematite_dir().join("PLAN.md")
137}
138
139pub fn save_plan_handoff(plan: &PlanHandoff) -> Result<(), String> {
140 let path = plan_path();
141 fs::create_dir_all(path.parent().unwrap()).map_err(|e| e.to_string())?;
142 fs::write(&path, plan.to_markdown()).map_err(|e| format!("Failed to write plan: {e}"))
143}
144
145pub fn load_plan_handoff() -> Option<PlanHandoff> {
146 let path = plan_path();
147 let content = fs::read_to_string(path).ok()?;
148 parse_plan_handoff(&content)
149}
150
151pub fn parse_plan_handoff(input: &str) -> Option<PlanHandoff> {
152 let sections = collect_sections(input);
153 let goal = sections
154 .get("goal")
155 .map(|s| s.trim().to_string())
156 .unwrap_or_default();
157 let target_files = parse_bullets(
158 sections
159 .get("target files")
160 .map(String::as_str)
161 .unwrap_or(""),
162 );
163 let ordered_steps = parse_ordered(
164 sections
165 .get("ordered steps")
166 .map(String::as_str)
167 .unwrap_or(""),
168 );
169 let verification = sections
170 .get("verification")
171 .map(|s| s.trim().to_string())
172 .unwrap_or_default();
173 let risks = parse_bullets(sections.get("risks").map(String::as_str).unwrap_or(""));
174 let open_questions = parse_bullets(
175 sections
176 .get("open questions")
177 .map(String::as_str)
178 .unwrap_or(""),
179 );
180
181 let plan = PlanHandoff {
182 goal,
183 target_files,
184 ordered_steps,
185 verification,
186 risks,
187 open_questions,
188 };
189 if plan.has_signal() && !plan.goal.trim().is_empty() && !plan.ordered_steps.is_empty() {
190 Some(plan)
191 } else {
192 None
193 }
194}
195
196fn collect_sections(input: &str) -> std::collections::BTreeMap<String, String> {
197 let mut sections = std::collections::BTreeMap::new();
198 let mut current: Option<String> = None;
199 let mut buf = String::new();
200
201 for line in input.lines() {
202 let trimmed = line.trim();
203 if let Some(name) = normalize_heading(trimmed) {
204 if let Some(prev) = current.replace(name) {
205 sections.insert(prev, buf.trim().to_string());
206 buf.clear();
207 }
208 continue;
209 }
210 if current.is_some() {
211 buf.push_str(line);
212 buf.push('\n');
213 }
214 }
215
216 if let Some(prev) = current {
217 sections.insert(prev, buf.trim().to_string());
218 }
219
220 sections
221}
222
223fn normalize_heading(line: &str) -> Option<String> {
224 let heading = line
225 .trim_start_matches('#')
226 .trim()
227 .trim_end_matches(':')
228 .trim();
229 match heading.to_ascii_lowercase().as_str() {
230 "goal" => Some("goal".to_string()),
231 "target files" => Some("target files".to_string()),
232 "ordered steps" => Some("ordered steps".to_string()),
233 "verification" => Some("verification".to_string()),
234 "risks" => Some("risks".to_string()),
235 "open questions" => Some("open questions".to_string()),
236 _ => None,
237 }
238}
239
240fn parse_bullets(section: &str) -> Vec<String> {
241 section
242 .lines()
243 .filter_map(|line| {
244 let trimmed = line.trim();
245 let stripped = trimmed
246 .strip_prefix("- ")
247 .or_else(|| trimmed.strip_prefix("* "))
248 .map(str::trim)?;
249 if stripped.is_empty()
250 || stripped.eq_ignore_ascii_case("none")
251 || stripped.eq_ignore_ascii_case("none specified")
252 {
253 None
254 } else {
255 Some(clean_bullet_path(stripped))
256 }
257 })
258 .filter(|s| !s.is_empty())
259 .collect()
260}
261
262fn clean_bullet_path(raw: &str) -> String {
265 let no_backticks = raw.replace('`', "");
267 let clean = if let Some(idx) = no_backticks.find(" (") {
269 no_backticks[..idx].trim()
270 } else {
271 no_backticks.trim()
272 };
273 clean.to_string()
274}
275
276fn parse_ordered(section: &str) -> Vec<String> {
277 let mut out = Vec::new();
278 for line in section.lines() {
279 let trimmed = line.trim();
280 let Some(dot_idx) = trimmed.find(". ") else {
281 continue;
282 };
283 if trimmed[..dot_idx].chars().all(|c| c.is_ascii_digit()) {
284 let step = trimmed[dot_idx + 2..].trim();
285 if !step.is_empty() {
286 out.push(step.to_string());
287 }
288 }
289 }
290 out
291}
292
293pub async fn maintain_plan(args: &Value) -> Result<String, String> {
295 let blueprint = args
296 .get("blueprint")
297 .and_then(|v| v.as_str())
298 .ok_or("maintain_plan: 'blueprint' (markdown text) required")?;
299 let plan_path = plan_path();
300
301 fs::create_dir_all(plan_path.parent().unwrap()).map_err(|e| e.to_string())?;
302 fs::write(&plan_path, blueprint).map_err(|e| format!("Failed to write plan: {e}"))?;
303
304 Ok(format!(
305 "Strategic Blueprint updated in .hematite/PLAN.md ({} bytes)",
306 blueprint.len()
307 ))
308}
309
310pub async fn generate_walkthrough(args: &Value) -> Result<String, String> {
312 let summary = args
313 .get("summary")
314 .and_then(|v| v.as_str())
315 .ok_or("generate_walkthrough: 'summary' required")?;
316 let path = hematite_dir().join("WALKTHROUGH.md");
317
318 fs::write(&path, summary).map_err(|e| format!("Failed to save walkthrough: {e}"))?;
319
320 Ok(format!(
321 "Walkthrough report saved to .hematite/WALKTHROUGH.md. Session complete!"
322 ))
323}
324
325pub fn get_plan_params() -> Value {
326 json!({
327 "type": "object",
328 "properties": {
329 "blueprint": {
330 "type": "string",
331 "description": "The full markdown content of the strategic blueprint."
332 }
333 },
334 "required": ["blueprint"]
335 })
336}
337
338pub fn get_walkthrough_params() -> Value {
339 json!({
340 "type": "object",
341 "properties": {
342 "summary": {
343 "type": "string",
344 "description": "The full markdown summary of accomplishments."
345 }
346 },
347 "required": ["summary"]
348 })
349}