1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::ctx_assembler::assemble_context;
6use crate::discovery::find_unit_file;
7use crate::prompt::{build_agent_prompt, FileOverlap, PromptOptions};
8use mana_core::ops::context::{assemble_agent_context, merge_paths, AgentContext, DepProvider};
9use mana_core::unit::Unit;
10
11fn format_rules_section(rules: &str) -> String {
15 format!(
16 "═══ PROJECT RULES ═══════════════════════════════════════════\n\
17 {}\n\
18 ═════════════════════════════════════════════════════════════\n\n",
19 rules.trim_end()
20 )
21}
22
23fn format_attempt_notes_section(notes: &str) -> String {
25 format!(
26 "═══ Previous Attempts ════════════════════════════════════════\n\
27 {}\n\
28 ══════════════════════════════════════════════════════════════\n\n",
29 notes.trim_end()
30 )
31}
32
33fn format_unit_spec_section(unit: &Unit) -> String {
35 let mut s = String::new();
36 s.push_str("═══ UNIT ════════════════════════════════════════════════════\n");
37 s.push_str(&format!("ID: {}\n", unit.id));
38 s.push_str(&format!("Title: {}\n", unit.title));
39 s.push_str(&format!("Priority: P{}\n", unit.priority));
40 s.push_str(&format!("Status: {}\n", unit.status));
41
42 if let Some(ref verify) = unit.verify {
43 s.push_str(&format!("Verify: {}\n", verify));
44 }
45
46 if !unit.produces.is_empty() {
47 s.push_str(&format!("Produces: {}\n", unit.produces.join(", ")));
48 }
49 if !unit.requires.is_empty() {
50 s.push_str(&format!("Requires: {}\n", unit.requires.join(", ")));
51 }
52 if !unit.dependencies.is_empty() {
53 s.push_str(&format!("Dependencies: {}\n", unit.dependencies.join(", ")));
54 }
55 if let Some(ref parent) = unit.parent {
56 s.push_str(&format!("Parent: {}\n", parent));
57 }
58
59 if !unit.decisions.is_empty() {
60 s.push_str(&format!(
61 "\n⚠ UNRESOLVED DECISIONS ({}):\n",
62 unit.decisions.len()
63 ));
64 for (i, decision) in unit.decisions.iter().enumerate() {
65 s.push_str(&format!(" {}: {}\n", i, decision));
66 }
67 }
68
69 if let Some(ref desc) = unit.description {
70 s.push_str(&format!("\n## Description\n{}\n", desc));
71 }
72 if let Some(ref acceptance) = unit.acceptance {
73 s.push_str(&format!("\n## Acceptance Criteria\n{}\n", acceptance));
74 }
75
76 s.push_str("═════════════════════════════════════════════════════════════\n\n");
77 s
78}
79
80fn format_dependency_section(providers: &[DepProvider]) -> Option<String> {
82 if providers.is_empty() {
83 return None;
84 }
85
86 let mut s = String::new();
87 s.push_str("═══ DEPENDENCY CONTEXT ══════════════════════════════════════\n");
88
89 for p in providers {
90 s.push_str(&format!(
91 "Unit {} ({}) produces `{}` [{}]\n",
92 p.unit_id, p.unit_title, p.artifact, p.status
93 ));
94 if let Some(ref desc) = p.description {
95 let preview: String = desc.chars().take(500).collect();
96 s.push_str(&format!("{}\n", preview));
97 if desc.len() > 500 {
98 s.push_str("...\n");
99 }
100 }
101 s.push('\n');
102 }
103
104 s.push_str("═════════════════════════════════════════════════════════════\n\n");
105 Some(s)
106}
107
108fn format_structure_block(structures: &[(&str, String)]) -> Option<String> {
110 if structures.is_empty() {
111 return None;
112 }
113
114 let mut body = String::new();
115 for (path, structure) in structures {
116 body.push_str(&format!("### {}\n```\n{}\n```\n\n", path, structure));
117 }
118
119 Some(format!(
120 "═══ File Structure ═══════════════════════════════════════════\n\
121 {}\
122 ══════════════════════════════════════════════════════════════\n\n",
123 body
124 ))
125}
126
127pub fn cmd_context(
131 mana_dir: &Path,
132 id: &str,
133 json: bool,
134 structure_only: bool,
135 agent_prompt: bool,
136 instructions: Option<String>,
137 overlaps_json: Option<String>,
138) -> Result<()> {
139 if agent_prompt {
141 let unit_path =
142 find_unit_file(mana_dir, id).context(format!("Could not find unit with ID: {}", id))?;
143 let unit = Unit::from_file(&unit_path).context(format!(
144 "Failed to parse unit from: {}",
145 unit_path.display()
146 ))?;
147
148 let concurrent_overlaps = match overlaps_json {
150 Some(ref s) => {
151 let raw: Vec<serde_json::Value> =
152 serde_json::from_str(s).context("Failed to parse --overlaps JSON")?;
153 let overlaps: Vec<FileOverlap> = raw
154 .into_iter()
155 .map(|v| FileOverlap {
156 unit_id: v["unit_id"].as_str().unwrap_or("").to_string(),
157 title: v["title"].as_str().unwrap_or("").to_string(),
158 shared_files: v["shared_files"]
159 .as_array()
160 .map(|arr| {
161 arr.iter()
162 .filter_map(|f| f.as_str().map(String::from))
163 .collect()
164 })
165 .unwrap_or_default(),
166 })
167 .collect();
168 Some(overlaps)
169 }
170 None => None,
171 };
172
173 let options = PromptOptions {
174 mana_dir: mana_dir.to_path_buf(),
175 instructions,
176 concurrent_overlaps,
177 };
178 let result = build_agent_prompt(&unit, &options)?;
179
180 if json {
181 let obj = serde_json::json!({
182 "system_prompt": result.system_prompt,
183 "user_message": result.user_message,
184 "file_ref": result.file_ref,
185 });
186 println!("{}", serde_json::to_string(&obj)?);
187 } else {
188 println!("{}", result.system_prompt);
189 }
190 return Ok(());
191 }
192
193 let ctx = assemble_agent_context(mana_dir, id)?;
195
196 let project_dir = mana_dir
197 .parent()
198 .ok_or_else(|| anyhow::anyhow!("Invalid .mana/ path: {}", mana_dir.display()))?;
199
200 if json {
201 output_json(&ctx, structure_only)?;
202 } else {
203 output_text(&ctx, project_dir, structure_only)?;
204 }
205
206 Ok(())
207}
208
209fn output_json(ctx: &AgentContext, structure_only: bool) -> Result<()> {
210 let files: Vec<serde_json::Value> = ctx
211 .files
212 .iter()
213 .map(|entry| {
214 let exists = entry.content.is_some();
215 let mut file_obj = serde_json::json!({
216 "path": entry.path,
217 "exists": exists,
218 });
219 if !structure_only {
220 file_obj["content"] = serde_json::Value::String(
221 entry
222 .content
223 .as_deref()
224 .unwrap_or("(not found)")
225 .to_string(),
226 );
227 }
228 if let Some(ref s) = entry.structure {
229 file_obj["structure"] = serde_json::Value::String(s.clone());
230 }
231 file_obj
232 })
233 .collect();
234
235 let dep_json: Vec<serde_json::Value> = ctx
236 .dep_providers
237 .iter()
238 .map(|p| {
239 serde_json::json!({
240 "artifact": p.artifact,
241 "unit_id": p.unit_id,
242 "title": p.unit_title,
243 "status": p.status,
244 "description": p.description,
245 })
246 })
247 .collect();
248
249 let unit = &ctx.unit;
250 let mut obj = serde_json::json!({
251 "id": unit.id,
252 "title": unit.title,
253 "priority": unit.priority,
254 "status": format!("{}", unit.status),
255 "verify": unit.verify,
256 "description": unit.description,
257 "acceptance": unit.acceptance,
258 "produces": unit.produces,
259 "requires": unit.requires,
260 "dependencies": unit.dependencies,
261 "parent": unit.parent,
262 "files": files,
263 "dependency_context": dep_json,
264 });
265 if let Some(ref rules_content) = ctx.rules {
266 obj["rules"] = serde_json::Value::String(rules_content.clone());
267 }
268 if let Some(ref notes) = ctx.attempt_notes {
269 obj["attempt_notes"] = serde_json::Value::String(notes.clone());
270 }
271 println!("{}", serde_json::to_string_pretty(&obj)?);
272
273 Ok(())
274}
275
276fn output_text(ctx: &AgentContext, project_dir: &Path, structure_only: bool) -> Result<()> {
277 let mut output = String::new();
278
279 output.push_str(&format_unit_spec_section(&ctx.unit));
281
282 if let Some(ref notes) = ctx.attempt_notes {
284 output.push_str(&format_attempt_notes_section(notes));
285 }
286
287 if let Some(ref rules_content) = ctx.rules {
289 output.push_str(&format_rules_section(rules_content));
290 }
291
292 if let Some(dep_section) = format_dependency_section(&ctx.dep_providers) {
294 output.push_str(&dep_section);
295 }
296
297 let structure_pairs: Vec<(&str, String)> = ctx
299 .files
300 .iter()
301 .filter_map(|e| e.structure.as_ref().map(|s| (e.path.as_str(), s.clone())))
302 .collect();
303
304 if let Some(structure_block) = format_structure_block(&structure_pairs) {
305 output.push_str(&structure_block);
306 }
307
308 if !structure_only {
310 let file_paths: Vec<String> = merge_paths(&ctx.unit);
311 if !file_paths.is_empty() {
312 let context =
313 assemble_context(file_paths, project_dir).context("Failed to assemble context")?;
314 output.push_str(&context);
315 }
316 }
317
318 print!("{}", output);
319
320 Ok(())
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use mana_core::ops::context::format_attempt_notes as core_format_attempt_notes;
327 use mana_core::ops::context::load_rules;
328 use std::fs;
329 use tempfile::TempDir;
330
331 fn setup_test_env() -> (TempDir, std::path::PathBuf) {
332 let dir = TempDir::new().unwrap();
333 let mana_dir = dir.path().join(".mana");
334 fs::create_dir(&mana_dir).unwrap();
335 (dir, mana_dir)
336 }
337
338 #[test]
339 fn context_with_no_paths_in_description() {
340 let (_dir, mana_dir) = setup_test_env();
341
342 let mut unit = crate::unit::Unit::new("1", "Test unit");
343 unit.description = Some("A description with no file paths".to_string());
344 let slug = crate::util::title_to_slug(&unit.title);
345 let unit_path = mana_dir.join(format!("1-{}.md", slug));
346 unit.to_file(&unit_path).unwrap();
347
348 let result = cmd_context(&mana_dir, "1", false, false, false, None, None);
349 assert!(result.is_ok());
350 }
351
352 #[test]
353 fn context_with_paths_in_description() {
354 let (dir, mana_dir) = setup_test_env();
355 let project_dir = dir.path();
356
357 let src_dir = project_dir.join("src");
358 fs::create_dir(&src_dir).unwrap();
359 fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
360
361 let mut unit = crate::unit::Unit::new("1", "Test unit");
362 unit.description = Some("Check src/foo.rs for implementation".to_string());
363 let slug = crate::util::title_to_slug(&unit.title);
364 let unit_path = mana_dir.join(format!("1-{}.md", slug));
365 unit.to_file(&unit_path).unwrap();
366
367 let result = cmd_context(&mana_dir, "1", false, false, false, None, None);
368 assert!(result.is_ok());
369 }
370
371 #[test]
372 fn context_unit_not_found() {
373 let (_dir, mana_dir) = setup_test_env();
374
375 let result = cmd_context(&mana_dir, "999", false, false, false, None, None);
376 assert!(result.is_err());
377 }
378
379 #[test]
380 fn load_rules_returns_none_when_file_missing() {
381 let (_dir, mana_dir) = setup_test_env();
382 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
383
384 let result = load_rules(&mana_dir);
385 assert!(result.is_none());
386 }
387
388 #[test]
389 fn load_rules_returns_none_when_file_empty() {
390 let (_dir, mana_dir) = setup_test_env();
391 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
392 fs::write(mana_dir.join("RULES.md"), " \n\n ").unwrap();
393
394 let result = load_rules(&mana_dir);
395 assert!(result.is_none());
396 }
397
398 #[test]
399 fn load_rules_returns_content_when_present() {
400 let (_dir, mana_dir) = setup_test_env();
401 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
402 fs::write(mana_dir.join("RULES.md"), "# My Rules\nNo unwrap.\n").unwrap();
403
404 let result = load_rules(&mana_dir);
405 assert!(result.is_some());
406 assert!(result.unwrap().contains("No unwrap."));
407 }
408
409 #[test]
410 fn load_rules_uses_custom_rules_file_path() {
411 let (_dir, mana_dir) = setup_test_env();
412 fs::write(
413 mana_dir.join("config.yaml"),
414 "project: test\nnext_id: 1\nrules_file: custom-rules.md\n",
415 )
416 .unwrap();
417 fs::write(mana_dir.join("custom-rules.md"), "Custom rules here").unwrap();
418
419 let result = load_rules(&mana_dir);
420 assert!(result.is_some());
421 assert!(result.unwrap().contains("Custom rules here"));
422 }
423
424 #[test]
425 fn format_rules_section_wraps_with_delimiters() {
426 let output = format_rules_section("# Rules\nBe nice.\n");
427 assert!(output.starts_with("═══ PROJECT RULES"));
428 assert!(output.contains("# Rules\nBe nice."));
429 assert!(
430 output.ends_with("═════════════════════════════════════════════════════════════\n\n")
431 );
432 }
433
434 fn make_unit_with_attempts() -> crate::unit::Unit {
437 use crate::unit::{AttemptOutcome, AttemptRecord};
438 let mut unit = crate::unit::Unit::new("1", "Test unit");
439 unit.attempt_log = vec![
440 AttemptRecord {
441 num: 1,
442 outcome: AttemptOutcome::Abandoned,
443 notes: Some("Tried X, hit bug Y".to_string()),
444 agent: Some("pi-agent".to_string()),
445 started_at: None,
446 finished_at: None,
447 },
448 AttemptRecord {
449 num: 2,
450 outcome: AttemptOutcome::Failed,
451 notes: Some("Fixed Y, now Z fails".to_string()),
452 agent: None,
453 started_at: None,
454 finished_at: None,
455 },
456 ];
457 unit
458 }
459
460 #[test]
461 fn format_attempt_notes_returns_none_when_no_notes() {
462 let unit = crate::unit::Unit::new("1", "Empty unit");
463 let result = core_format_attempt_notes(&unit);
464 assert!(result.is_none());
465 }
466
467 #[test]
468 fn format_attempt_notes_returns_none_when_attempts_have_no_notes() {
469 use crate::unit::{AttemptOutcome, AttemptRecord};
470 let mut unit = crate::unit::Unit::new("1", "Empty unit");
471 unit.attempt_log = vec![AttemptRecord {
472 num: 1,
473 outcome: AttemptOutcome::Abandoned,
474 notes: None,
475 agent: None,
476 started_at: None,
477 finished_at: None,
478 }];
479 let result = core_format_attempt_notes(&unit);
480 assert!(result.is_none());
481 }
482
483 #[test]
484 fn format_attempt_notes_includes_attempt_log_notes() {
485 let unit = make_unit_with_attempts();
486 let result = core_format_attempt_notes(&unit).expect("should produce output");
487 assert!(result.contains("Attempt #1"), "should include attempt 1");
488 assert!(result.contains("pi-agent"), "should include agent name");
489 assert!(result.contains("abandoned"), "should include outcome");
490 assert!(
491 result.contains("Tried X, hit bug Y"),
492 "should include notes text"
493 );
494 assert!(result.contains("Attempt #2"), "should include attempt 2");
495 assert!(
496 result.contains("Fixed Y, now Z fails"),
497 "should include attempt 2 notes"
498 );
499 }
500
501 #[test]
502 fn format_attempt_notes_includes_unit_notes() {
503 let mut unit = crate::unit::Unit::new("1", "Test unit");
504 unit.notes = Some("Watch out for edge cases".to_string());
505 let result = core_format_attempt_notes(&unit).expect("should produce output");
506 assert!(result.contains("Watch out for edge cases"));
507 assert!(result.contains("Unit notes:"));
508 }
509
510 #[test]
511 fn format_attempt_notes_skips_empty_notes_strings() {
512 use crate::unit::{AttemptOutcome, AttemptRecord};
513 let mut unit = crate::unit::Unit::new("1", "Test unit");
514 unit.notes = Some(" ".to_string());
515 unit.attempt_log = vec![AttemptRecord {
516 num: 1,
517 outcome: AttemptOutcome::Abandoned,
518 notes: Some(" ".to_string()),
519 agent: None,
520 started_at: None,
521 finished_at: None,
522 }];
523 let result = core_format_attempt_notes(&unit);
524 assert!(
525 result.is_none(),
526 "whitespace-only notes should produce no output"
527 );
528 }
529
530 #[test]
531 fn context_includes_attempt_notes_in_text_output() {
532 let (dir, mana_dir) = setup_test_env();
533 let project_dir = dir.path();
534
535 let src_dir = project_dir.join("src");
536 fs::create_dir(&src_dir).unwrap();
537 fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
538
539 let mut unit = make_unit_with_attempts();
540 unit.id = "1".to_string();
541 unit.description = Some("Check src/foo.rs for implementation".to_string());
542 let slug = crate::util::title_to_slug(&unit.title);
543 let unit_path = mana_dir.join(format!("1-{}.md", slug));
544 unit.to_file(&unit_path).unwrap();
545
546 let result = cmd_context(&mana_dir, "1", false, false, false, None, None);
547 assert!(result.is_ok());
548 }
549
550 #[test]
551 fn context_includes_attempt_notes_in_json_output() {
552 let (dir, mana_dir) = setup_test_env();
553 let project_dir = dir.path();
554
555 let src_dir = project_dir.join("src");
556 fs::create_dir(&src_dir).unwrap();
557 fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
558
559 let mut unit = make_unit_with_attempts();
560 unit.id = "1".to_string();
561 unit.description = Some("Check src/foo.rs for implementation".to_string());
562 let slug = crate::util::title_to_slug(&unit.title);
563 let unit_path = mana_dir.join(format!("1-{}.md", slug));
564 unit.to_file(&unit_path).unwrap();
565
566 let result = cmd_context(&mana_dir, "1", true, false, false, None, None);
567 assert!(result.is_ok());
568 }
569}