Skip to main content

mana/commands/
context.rs

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
11// ─── Formatting helpers (CLI-only) ──────────────────────────────────────────
12
13/// Format rules content with delimiters for agent context injection.
14fn format_rules_section(rules: &str) -> String {
15    format!(
16        "═══ PROJECT RULES ═══════════════════════════════════════════\n\
17         {}\n\
18         ═════════════════════════════════════════════════════════════\n\n",
19        rules.trim_end()
20    )
21}
22
23/// Format attempt notes with delimiters.
24fn 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
33/// Format the unit's core spec as the first section of the context output.
34fn 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
80/// Format dependency providers into a section for the context output.
81fn 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
108/// Format multiple file structures into a single section.
109fn 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
127// ─── Command ─────────────────────────────────────────────────────────────────
128
129/// Assemble complete agent context for a unit — the single source of truth.
130pub 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    // --agent-prompt: output the full structured prompt that an agent sees during mana run
140    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        // Parse --overlaps JSON into FileOverlap structs
149        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    // Delegate data assembly to core
194    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    // 1. Unit spec
280    output.push_str(&format_unit_spec_section(&ctx.unit));
281
282    // 2. Previous attempts
283    if let Some(ref notes) = ctx.attempt_notes {
284        output.push_str(&format_attempt_notes_section(notes));
285    }
286
287    // 3. Project rules
288    if let Some(ref rules_content) = ctx.rules {
289        output.push_str(&format_rules_section(rules_content));
290    }
291
292    // 4. Dependency context
293    if let Some(dep_section) = format_dependency_section(&ctx.dep_providers) {
294        output.push_str(&dep_section);
295    }
296
297    // 5. Structural summaries
298    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    // 6. Full file contents (unless --structure-only)
309    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    // --- attempt notes tests (delegated to core) ---
435
436    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}