1use std::path::Path;
2
3use anyhow::Result;
4use chrono::{Duration, Utc};
5
6use crate::discovery::{find_archived_unit, find_unit_file};
7use crate::index::Index;
8use crate::relevance::relevance_score;
9use crate::unit::{AttemptOutcome, Status, Unit};
10
11const DEFAULT_MAX_CHARS: usize = 16000;
13
14pub fn cmd_memory_context(mana_dir: &Path, json: bool) -> Result<()> {
22 let now = Utc::now();
23 let index = Index::load_or_rebuild(mana_dir)?;
24 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
25
26 let mut working_paths: Vec<String> = Vec::new();
28 let mut working_deps: Vec<String> = Vec::new();
29
30 let mut warnings: Vec<String> = Vec::new();
34
35 let mut working_on: Vec<String> = Vec::new();
39
40 for entry in &index.units {
41 if entry.status != Status::InProgress {
42 continue;
43 }
44
45 let unit_path = match find_unit_file(mana_dir, &entry.id) {
46 Ok(p) => p,
47 Err(_) => continue,
48 };
49
50 let unit = match Unit::from_file(&unit_path) {
51 Ok(b) => b,
52 Err(_) => continue,
53 };
54
55 working_paths.extend(unit.paths.clone());
57 working_deps.extend(unit.requires.clone());
58 working_deps.extend(unit.produces.clone());
59
60 let mut line = format!("[{}] {}", unit.id, unit.title);
61
62 let failed_attempts: Vec<_> = unit
64 .attempt_log
65 .iter()
66 .filter(|a| a.outcome == AttemptOutcome::Failed)
67 .collect();
68
69 if !failed_attempts.is_empty() {
70 line.push_str(&format!(
71 "\n│ Attempt #{} (previous failures: {})",
72 failed_attempts.len() + 1,
73 failed_attempts.len()
74 ));
75 if let Some(last) = failed_attempts.last() {
77 if let Some(ref notes) = last.notes {
78 let preview: String = notes.chars().take(100).collect();
79 line.push_str(&format!("\n│ Last failure: {}", preview));
80
81 warnings.push(format!(
83 "PAST FAILURE [{}]: \"{}\"",
84 unit.id,
85 notes.chars().take(80).collect::<String>()
86 ));
87 }
88 }
89 }
90
91 working_on.push(line);
92 }
93
94 for entry in index.units.iter().chain(archived.iter()) {
96 let unit_path = match find_unit_file(mana_dir, &entry.id)
97 .or_else(|_| find_archived_unit(mana_dir, &entry.id))
98 {
99 Ok(p) => p,
100 Err(_) => continue,
101 };
102
103 let unit = match Unit::from_file(&unit_path) {
104 Ok(b) => b,
105 Err(_) => continue,
106 };
107
108 if unit.unit_type != "fact" {
109 continue;
110 }
111
112 if let Some(stale_after) = unit.stale_after {
114 if now > stale_after {
115 let days_stale = (now - stale_after).num_days();
116 warnings.push(format!(
117 "STALE: \"{}\" — not verified in {}d",
118 unit.title, days_stale
119 ));
120 }
121 }
122 }
123
124 let mut relevant_facts: Vec<(Unit, u32)> = Vec::new();
128
129 for entry in index.units.iter().chain(archived.iter()) {
130 let unit_path = match find_unit_file(mana_dir, &entry.id)
131 .or_else(|_| find_archived_unit(mana_dir, &entry.id))
132 {
133 Ok(p) => p,
134 Err(_) => continue,
135 };
136
137 let unit = match Unit::from_file(&unit_path) {
138 Ok(b) => b,
139 Err(_) => continue,
140 };
141
142 if unit.unit_type != "fact" {
143 continue;
144 }
145
146 let score = relevance_score(&unit, &working_paths, &working_deps);
147 if score > 0 {
148 relevant_facts.push((unit, score));
149 }
150 }
151
152 relevant_facts.sort_by(|a, b| b.1.cmp(&a.1));
153
154 let mut recent_work: Vec<Unit> = Vec::new();
158 let seven_days_ago = now - Duration::days(7);
159
160 for entry in &archived {
161 if entry.status != Status::Closed {
162 continue;
163 }
164
165 let unit_path = match find_archived_unit(mana_dir, &entry.id) {
166 Ok(p) => p,
167 Err(_) => continue,
168 };
169
170 let unit = match Unit::from_file(&unit_path) {
171 Ok(b) => b,
172 Err(_) => continue,
173 };
174
175 if unit.unit_type == "fact" {
176 continue; }
178
179 if let Some(closed_at) = unit.closed_at {
180 if closed_at > seven_days_ago {
181 recent_work.push(unit);
182 }
183 }
184 }
185
186 recent_work.sort_by(|a, b| b.closed_at.unwrap_or(now).cmp(&a.closed_at.unwrap_or(now)));
187
188 if json {
193 let output = serde_json::json!({
194 "warnings": warnings,
195 "working_on": working_on.iter().map(|w| {
196 w.split(']').next().unwrap_or("").trim_start_matches('[').to_string()
198 }).collect::<Vec<_>>(),
199 "relevant_facts": relevant_facts.iter().map(|(b, s)| {
200 serde_json::json!({
201 "id": b.id,
202 "title": b.title,
203 "score": s,
204 "verified": b.last_verified,
205 })
206 }).collect::<Vec<_>>(),
207 "recent_work": recent_work.iter().map(|b| {
208 serde_json::json!({
209 "id": b.id,
210 "title": b.title,
211 "closed_at": b.closed_at,
212 "close_reason": b.close_reason,
213 })
214 }).collect::<Vec<_>>(),
215 });
216 println!("{}", serde_json::to_string_pretty(&output)?);
217 return Ok(());
218 }
219
220 let has_content = !warnings.is_empty()
222 || !working_on.is_empty()
223 || !relevant_facts.is_empty()
224 || !recent_work.is_empty();
225
226 if !has_content {
227 println!("No memory context available.");
228 return Ok(());
229 }
230
231 let mut output = String::new();
232 #[allow(unused_assignments)]
233 let mut chars_used = 0;
234
235 output.push_str("═══ UNITS CONTEXT ═══════════════════════════════════════════\n\n");
236
237 if !warnings.is_empty() {
239 output.push_str("⚠ WARNINGS\n");
240 for w in &warnings {
241 output.push_str(&format!("│ {}\n", w));
242 }
243 output.push('\n');
244 }
245
246 if !working_on.is_empty() {
248 output.push_str("► WORKING ON\n");
249 for w in &working_on {
250 output.push_str(&format!("│ {}\n", w));
251 }
252 output.push('\n');
253 }
254
255 chars_used = output.len();
256
257 if !relevant_facts.is_empty() && chars_used < DEFAULT_MAX_CHARS {
259 output.push_str("✓ RELEVANT FACTS\n");
260 for (unit, _score) in &relevant_facts {
261 if chars_used > DEFAULT_MAX_CHARS {
262 break;
263 }
264 let verified_ago = unit
265 .last_verified
266 .map(|lv| {
267 let ago = now - lv;
268 if ago.num_days() > 0 {
269 format!("✓ {}d ago", ago.num_days())
270 } else if ago.num_hours() > 0 {
271 format!("✓ {}h ago", ago.num_hours())
272 } else {
273 "✓ just now".to_string()
274 }
275 })
276 .unwrap_or_else(|| "unverified".to_string());
277
278 let line = format!("│ \"{}\" {}\n", unit.title, verified_ago);
279 chars_used += line.len();
280 output.push_str(&line);
281 }
282 output.push('\n');
283 }
284
285 if !recent_work.is_empty() && chars_used < DEFAULT_MAX_CHARS {
287 output.push_str("◷ RECENT WORK\n");
288 for unit in &recent_work {
289 if chars_used > DEFAULT_MAX_CHARS {
290 break;
291 }
292 let closed_ago = unit
293 .closed_at
294 .map(|ca| {
295 let ago = now - ca;
296 if ago.num_days() > 0 {
297 format!("{}d ago", ago.num_days())
298 } else if ago.num_hours() > 0 {
299 format!("{}h ago", ago.num_hours())
300 } else {
301 "just now".to_string()
302 }
303 })
304 .unwrap_or_else(|| "recently".to_string());
305
306 let mut line = format!("│ [{}] {} (closed {})\n", unit.id, unit.title, closed_ago);
307
308 if let Some(ref reason) = unit.close_reason {
309 line.push_str(&format!(
310 "│ \"{}\"\n",
311 reason.chars().take(80).collect::<String>()
312 ));
313 }
314
315 chars_used += line.len();
316 output.push_str(&line);
317 }
318 output.push('\n');
319 }
320
321 print!("{}", output);
322
323 Ok(())
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::fs;
330 use tempfile::TempDir;
331
332 fn setup_mana_dir_with_config() -> (TempDir, std::path::PathBuf) {
333 let dir = TempDir::new().unwrap();
334 let mana_dir = dir.path().join(".mana");
335 fs::create_dir(&mana_dir).unwrap();
336
337 let config = crate::config::Config {
338 project: "test".to_string(),
339 next_id: 10,
340 auto_close_parent: true,
341 run: None,
342 plan: None,
343 max_loops: 10,
344 max_concurrent: 4,
345 poll_interval: 30,
346 extends: vec![],
347 rules_file: None,
348 file_locking: false,
349 worktree: false,
350 on_close: None,
351 on_fail: None,
352 post_plan: None,
353 verify_timeout: None,
354 review: None,
355 user: None,
356 user_email: None,
357 auto_commit: false,
358 commit_template: None,
359 research: None,
360 run_model: None,
361 plan_model: None,
362 review_model: None,
363 research_model: None,
364 batch_verify: false,
365 memory_reserve_mb: 0,
366 notify: None,
367 };
368 config.save(&mana_dir).unwrap();
369
370 (dir, mana_dir)
371 }
372
373 #[test]
374 fn memory_context_empty() {
375 let (_dir, mana_dir) = setup_mana_dir_with_config();
376
377 let result = cmd_memory_context(&mana_dir, false);
379 assert!(result.is_ok());
380 }
381
382 #[test]
383 fn memory_context_shows_claimed_units() {
384 let (_dir, mana_dir) = setup_mana_dir_with_config();
385
386 let mut unit = Unit::new("1", "Working on auth");
388 unit.status = Status::InProgress;
389 unit.claimed_by = Some("agent-1".to_string());
390 unit.claimed_at = Some(Utc::now());
391 let slug = crate::util::title_to_slug(&unit.title);
392 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
393 .unwrap();
394
395 let result = cmd_memory_context(&mana_dir, false);
396 assert!(result.is_ok());
397 }
398
399 #[test]
400 fn memory_context_shows_stale_facts() {
401 let (_dir, mana_dir) = setup_mana_dir_with_config();
402
403 let mut unit = Unit::new("1", "Auth uses RS256");
405 unit.unit_type = "fact".to_string();
406 unit.stale_after = Some(Utc::now() - Duration::days(5)); unit.verify = Some("true".to_string());
408 let slug = crate::util::title_to_slug(&unit.title);
409 unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
410 .unwrap();
411
412 let result = cmd_memory_context(&mana_dir, false);
413 assert!(result.is_ok());
414 }
415
416 #[test]
417 fn memory_context_json_output() {
418 let (_dir, mana_dir) = setup_mana_dir_with_config();
419
420 let result = cmd_memory_context(&mana_dir, true);
421 assert!(result.is_ok());
422 }
423}