Skip to main content

mana_core/ops/
context.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::Serialize;
6
7use crate::config::Config;
8use crate::ctx_assembler::{extract_paths, read_file};
9use crate::discovery::find_unit_file;
10use crate::index::Index;
11use crate::resolve::resolve_unit;
12use crate::sqlite;
13use crate::unit::{AttemptOutcome, Status, Unit};
14
15// ─── Result types ────────────────────────────────────────────────────────────
16
17/// Information about a sibling unit that produces an artifact this unit requires.
18pub struct DepProvider {
19    pub artifact: String,
20    pub unit_id: String,
21    pub unit_title: String,
22    pub status: String,
23    pub description: Option<String>,
24}
25
26/// A file referenced by the unit with its content and structural summary.
27pub struct FileEntry {
28    pub path: String,
29    pub content: Option<String>,
30    pub structure: Option<String>,
31}
32
33/// Concise parent-oriented summary of a direct child unit.
34#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct ChildSummary {
36    pub id: String,
37    pub title: String,
38    pub status: String,
39    pub attempts: usize,
40    pub recent_outcome: Option<String>,
41    pub summary: Option<String>,
42    pub follow_up: Option<String>,
43}
44
45/// Fully assembled agent context for a unit.
46pub struct AgentContext {
47    pub unit: Unit,
48    pub rules: Option<String>,
49    pub attempt_notes: Option<String>,
50    pub dep_providers: Vec<DepProvider>,
51    pub child_summaries: Vec<ChildSummary>,
52    pub files: Vec<FileEntry>,
53}
54
55// ─── Core operations ─────────────────────────────────────────────────────────
56
57/// Assemble full agent context for a unit — the structured data needed
58/// to build any output format (text, JSON, agent prompt).
59///
60/// Loads the unit, resolves dependency context, merges file paths from
61/// explicit `unit.paths` and regex-extracted paths from the description,
62/// reads file contents, and extracts structural summaries.
63pub fn assemble_agent_context(mana_dir: &Path, id: &str) -> Result<AgentContext> {
64    let resolved = resolve_unit(mana_dir, id)?;
65    let _unit_path = resolved.path;
66    let unit = resolved.unit;
67
68    let project_dir = mana_dir
69        .parent()
70        .ok_or_else(|| anyhow::anyhow!("Invalid .mana/ path: {}", mana_dir.display()))?;
71
72    let paths = merge_paths(&unit);
73    let rules = load_rules(mana_dir);
74    let attempt_notes = format_attempt_notes(&unit);
75    let dep_providers = resolve_dependency_context_sqlite(mana_dir, &unit)
76        .unwrap_or_else(|_| resolve_dependency_context(mana_dir, &unit));
77    let child_summaries = summarize_child_units_sqlite(mana_dir, &unit.id)
78        .unwrap_or_else(|_| summarize_child_units(mana_dir, &unit.id));
79
80    let canonical_base = project_dir
81        .canonicalize()
82        .context("Cannot canonicalize project dir")?;
83
84    let mut files: Vec<FileEntry> = Vec::new();
85    for path_str in &paths {
86        let full_path = project_dir.join(path_str);
87        let canonical = full_path.canonicalize().ok();
88
89        let in_bounds = canonical
90            .as_ref()
91            .map(|c| c.starts_with(&canonical_base))
92            .unwrap_or(false);
93
94        let content = if let Some(ref c) = canonical {
95            if in_bounds {
96                read_file(c).ok()
97            } else {
98                None
99            }
100        } else {
101            None
102        };
103
104        let structure = content
105            .as_deref()
106            .and_then(|c| extract_file_structure(path_str, c));
107
108        files.push(FileEntry {
109            path: path_str.clone(),
110            content,
111            structure,
112        });
113    }
114
115    Ok(AgentContext {
116        unit,
117        rules,
118        attempt_notes,
119        dep_providers,
120        child_summaries,
121        files,
122    })
123}
124
125// ─── Rules loading ───────────────────────────────────────────────────────────
126
127/// Load project rules from the configured rules file.
128///
129/// Returns `None` if the file doesn't exist or is empty.
130pub fn load_rules(mana_dir: &Path) -> Option<String> {
131    let config = Config::load_with_extends(mana_dir).ok()?;
132    let rules_path = config.rules_path(mana_dir);
133
134    let content = std::fs::read_to_string(&rules_path).ok()?;
135    let trimmed = content.trim();
136
137    if trimmed.is_empty() {
138        return None;
139    }
140
141    let line_count = content.lines().count();
142    if line_count > 1000 {
143        eprintln!(
144            "Warning: RULES.md is very large ({} lines). Consider trimming it.",
145            line_count
146        );
147    }
148
149    Some(content)
150}
151
152// ─── Attempt notes ───────────────────────────────────────────────────────────
153
154/// Format the attempt_log and notes field into a combined notes string.
155///
156/// Returns `None` if there are no attempt notes and no unit notes.
157pub fn format_attempt_notes(unit: &Unit) -> Option<String> {
158    let mut parts: Vec<String> = Vec::new();
159
160    if let Some(ref notes) = unit.notes {
161        let trimmed = notes.trim();
162        if !trimmed.is_empty() {
163            parts.push(format!("Unit notes:\n{}", trimmed));
164        }
165    }
166
167    let attempt_entries: Vec<String> = unit
168        .attempt_log
169        .iter()
170        .filter_map(|a| {
171            let notes = a.notes.as_deref()?.trim();
172            if notes.is_empty() {
173                return None;
174            }
175            let outcome = match a.outcome {
176                AttemptOutcome::Success => "success",
177                AttemptOutcome::Failed => "failed",
178                AttemptOutcome::Abandoned => "abandoned",
179            };
180            let agent_str = a
181                .agent
182                .as_deref()
183                .map(|ag| format!(" ({})", ag))
184                .unwrap_or_default();
185            Some(format!(
186                "Attempt #{}{} [{}]: {}",
187                a.num, agent_str, outcome, notes
188            ))
189        })
190        .collect();
191
192    if !attempt_entries.is_empty() {
193        parts.push(attempt_entries.join("\n"));
194    }
195
196    if parts.is_empty() {
197        return None;
198    }
199
200    Some(parts.join("\n\n"))
201}
202
203// ─── Dependency context ──────────────────────────────────────────────────────
204
205pub fn resolve_dependency_context_sqlite(mana_dir: &Path, unit: &Unit) -> Result<Vec<DepProvider>> {
206    let sqlite = open_fresh_sqlite_index(mana_dir)?;
207    if let Some(message) = sqlite.invalid_relevant_diagnostic(&unit.id)? {
208        anyhow::bail!("invalid mana unit {}: {}", unit.id, message);
209    }
210
211    let rows = sqlite.dependency_providers(&unit.id, unit.parent.as_deref(), &unit.requires)?;
212    Ok(rows
213        .into_iter()
214        .map(|row| DepProvider {
215            artifact: row.artifact,
216            unit_id: row.unit_id,
217            unit_title: row.unit_title,
218            status: row.status,
219            description: row.description,
220        })
221        .collect())
222}
223
224/// Resolve dependency context: find sibling units that produce artifacts
225/// this unit requires, and load their descriptions.
226pub fn resolve_dependency_context(mana_dir: &Path, unit: &Unit) -> Vec<DepProvider> {
227    if unit.requires.is_empty() {
228        return Vec::new();
229    }
230
231    let index = match Index::load_or_rebuild(mana_dir) {
232        Ok(idx) => idx,
233        Err(_) => return Vec::new(),
234    };
235
236    let mut providers = Vec::new();
237
238    for required in &unit.requires {
239        let producer = index
240            .units
241            .iter()
242            .find(|e| e.id != unit.id && e.parent == unit.parent && e.produces.contains(required));
243
244        if let Some(entry) = producer {
245            let desc = find_unit_file(mana_dir, &entry.id)
246                .ok()
247                .and_then(|p| Unit::from_file(&p).ok())
248                .and_then(|b| b.description.clone());
249
250            providers.push(DepProvider {
251                artifact: required.clone(),
252                unit_id: entry.id.clone(),
253                unit_title: entry.title.clone(),
254                status: format!("{}", entry.status),
255                description: desc,
256            });
257        }
258    }
259
260    providers
261}
262
263pub fn summarize_child_units_sqlite(mana_dir: &Path, parent_id: &str) -> Result<Vec<ChildSummary>> {
264    let sqlite = open_fresh_sqlite_index(mana_dir)?;
265    if let Some(message) = sqlite.invalid_relevant_diagnostic(parent_id)? {
266        anyhow::bail!("invalid mana unit {}: {}", parent_id, message);
267    }
268
269    let rows = sqlite.child_summaries(parent_id)?;
270    Ok(rows
271        .into_iter()
272        .map(|row| ChildSummary {
273            id: row.id,
274            title: row.title,
275            status: row.status,
276            attempts: row.attempts,
277            recent_outcome: row.recent_outcome,
278            summary: row.summary,
279            follow_up: row.follow_up,
280        })
281        .collect())
282}
283
284/// Summarize direct child tasks for parent-oriented views.
285pub fn summarize_child_units(mana_dir: &Path, parent_id: &str) -> Vec<ChildSummary> {
286    let index = match Index::load_or_rebuild(mana_dir) {
287        Ok(idx) => idx,
288        Err(_) => return Vec::new(),
289    };
290
291    let mut children: Vec<_> = index
292        .units
293        .iter()
294        .filter(|entry| entry.parent.as_deref() == Some(parent_id))
295        .cloned()
296        .collect();
297    children.sort_by(|a, b| crate::util::natural_cmp(&a.id, &b.id));
298
299    children
300        .into_iter()
301        .map(|entry| {
302            let full_unit = find_unit_file(mana_dir, &entry.id)
303                .ok()
304                .and_then(|path| Unit::from_file(path).ok());
305
306            let recent_outcome = full_unit
307                .as_ref()
308                .and_then(latest_attempt_outcome)
309                .or_else(|| status_implied_outcome(entry.status));
310            let summary = full_unit.as_ref().and_then(summarize_child_signal);
311            let follow_up = full_unit.as_ref().and_then(summarize_child_follow_up);
312
313            ChildSummary {
314                id: entry.id,
315                title: entry.title,
316                status: entry.status.to_string(),
317                attempts: full_unit
318                    .as_ref()
319                    .map(|unit| unit.attempt_log.len())
320                    .unwrap_or(0),
321                recent_outcome,
322                summary,
323                follow_up,
324            }
325        })
326        .collect()
327}
328
329fn open_fresh_sqlite_index(mana_dir: &Path) -> Result<sqlite::Index> {
330    sqlite::Index::rebuild(mana_dir)?;
331    sqlite::Index::open(mana_dir)
332}
333
334fn latest_attempt_outcome(unit: &Unit) -> Option<String> {
335    unit.attempt_log
336        .last()
337        .map(|attempt| match attempt.outcome {
338            AttemptOutcome::Success => "success".to_string(),
339            AttemptOutcome::Failed => "failed".to_string(),
340            AttemptOutcome::Abandoned => "abandoned".to_string(),
341        })
342}
343
344fn status_implied_outcome(status: Status) -> Option<String> {
345    match status {
346        Status::Closed => Some("success".to_string()),
347        Status::AwaitingVerify => Some("awaiting_verify".to_string()),
348        Status::InProgress => Some("in_progress".to_string()),
349        Status::Open => None,
350    }
351}
352
353fn summarize_child_signal(unit: &Unit) -> Option<String> {
354    if let Some(summary) = summarize_text(unit.close_reason.as_deref()) {
355        return Some(summary);
356    }
357    if let Some(summary) = summarize_text(unit.notes.as_deref()) {
358        return Some(summary);
359    }
360    if let Some(summary) = summarize_text(
361        unit.attempt_log
362            .iter()
363            .rev()
364            .find_map(|attempt| attempt.notes.as_deref()),
365    ) {
366        return Some(summary);
367    }
368    unit.outputs
369        .as_ref()
370        .and_then(|outputs| summarize_text(Some(&outputs.to_string())))
371}
372
373fn summarize_child_follow_up(unit: &Unit) -> Option<String> {
374    if !unit.decisions.is_empty() {
375        return Some(format!("{} unresolved decision(s)", unit.decisions.len()));
376    }
377
378    if unit.status != Status::Closed {
379        if unit.verify.is_some() {
380            return Some("still needs completion/verify".to_string());
381        }
382        return Some("still open".to_string());
383    }
384
385    None
386}
387
388fn summarize_text(text: Option<&str>) -> Option<String> {
389    let text = text?.trim();
390    if text.is_empty() {
391        return None;
392    }
393
394    let single_line = text.lines().find(|line| !line.trim().is_empty())?.trim();
395    let mut summary = single_line.chars().take(140).collect::<String>();
396    if single_line.chars().count() > 140 {
397        summary.push('…');
398    }
399    Some(summary)
400}
401
402// ─── Path merging ────────────────────────────────────────────────────────────
403
404/// Merge explicit `unit.paths` with paths regex-extracted from the description.
405/// Explicit paths come first, then regex-extracted paths fill gaps.
406pub fn merge_paths(unit: &Unit) -> Vec<String> {
407    let mut seen = HashSet::new();
408    let mut result = Vec::new();
409
410    for p in &unit.paths {
411        if seen.insert(p.clone()) {
412            result.push(p.clone());
413        }
414    }
415
416    let description = unit.description.as_deref().unwrap_or("");
417    for p in extract_paths(description) {
418        if seen.insert(p.clone()) {
419            result.push(p);
420        }
421    }
422
423    result
424}
425
426// ─── Structure extraction ────────────────────────────────────────────────────
427
428/// Extract a structural summary (signatures, imports) from file content.
429///
430/// Dispatches to language-specific extractors based on file extension.
431/// Returns `None` for unrecognized file types or when no structure is found.
432pub fn extract_file_structure(path: &str, content: &str) -> Option<String> {
433    let ext = Path::new(path).extension()?.to_str()?;
434
435    let lines: Vec<String> = match ext {
436        "rs" => extract_rust_structure(content),
437        "ts" | "tsx" => extract_ts_structure(content),
438        "py" => extract_python_structure(content),
439        _ => return None,
440    };
441
442    if lines.is_empty() {
443        return None;
444    }
445
446    Some(lines.join("\n"))
447}
448
449fn extract_rust_structure(content: &str) -> Vec<String> {
450    let mut result = Vec::new();
451
452    for line in content.lines() {
453        let trimmed = line.trim();
454
455        if trimmed.is_empty()
456            || trimmed.starts_with("//")
457            || trimmed.starts_with("/*")
458            || trimmed.starts_with('*')
459        {
460            continue;
461        }
462
463        if trimmed.starts_with("use ") {
464            result.push(trimmed.to_string());
465            continue;
466        }
467
468        let is_decl = trimmed.starts_with("pub fn ")
469            || trimmed.starts_with("pub async fn ")
470            || trimmed.starts_with("pub(crate) fn ")
471            || trimmed.starts_with("pub(crate) async fn ")
472            || trimmed.starts_with("fn ")
473            || trimmed.starts_with("async fn ")
474            || trimmed.starts_with("pub struct ")
475            || trimmed.starts_with("pub(crate) struct ")
476            || trimmed.starts_with("struct ")
477            || trimmed.starts_with("pub enum ")
478            || trimmed.starts_with("pub(crate) enum ")
479            || trimmed.starts_with("enum ")
480            || trimmed.starts_with("pub trait ")
481            || trimmed.starts_with("pub(crate) trait ")
482            || trimmed.starts_with("trait ")
483            || trimmed.starts_with("pub type ")
484            || trimmed.starts_with("type ")
485            || trimmed.starts_with("impl ")
486            || trimmed.starts_with("pub const ")
487            || trimmed.starts_with("pub(crate) const ")
488            || trimmed.starts_with("const ")
489            || trimmed.starts_with("pub static ")
490            || trimmed.starts_with("static ");
491
492        if is_decl {
493            let sig = trimmed.trim_end_matches('{').trim_end();
494            result.push(sig.to_string());
495        }
496    }
497
498    result
499}
500
501fn extract_ts_structure(content: &str) -> Vec<String> {
502    let mut result = Vec::new();
503
504    for line in content.lines() {
505        let trimmed = line.trim();
506
507        if trimmed.is_empty()
508            || trimmed.starts_with("//")
509            || trimmed.starts_with("/*")
510            || trimmed.starts_with('*')
511        {
512            continue;
513        }
514
515        if trimmed.starts_with("import ") {
516            result.push(trimmed.to_string());
517            continue;
518        }
519
520        let is_decl = trimmed.starts_with("export function ")
521            || trimmed.starts_with("export async function ")
522            || trimmed.starts_with("export default function ")
523            || trimmed.starts_with("function ")
524            || trimmed.starts_with("async function ")
525            || trimmed.starts_with("export class ")
526            || trimmed.starts_with("export abstract class ")
527            || trimmed.starts_with("class ")
528            || trimmed.starts_with("export interface ")
529            || trimmed.starts_with("interface ")
530            || trimmed.starts_with("export type ")
531            || trimmed.starts_with("export enum ")
532            || trimmed.starts_with("export const ")
533            || trimmed.starts_with("export default class ")
534            || trimmed.starts_with("export default async function ");
535
536        if is_decl {
537            let sig = trimmed.trim_end_matches('{').trim_end();
538            result.push(sig.to_string());
539        }
540    }
541
542    result
543}
544
545fn extract_python_structure(content: &str) -> Vec<String> {
546    let mut result = Vec::new();
547
548    for line in content.lines() {
549        let trimmed = line.trim();
550
551        if trimmed.is_empty() || trimmed.starts_with('#') {
552            continue;
553        }
554
555        if line.starts_with("import ") || line.starts_with("from ") {
556            result.push(trimmed.to_string());
557            continue;
558        }
559
560        if trimmed.starts_with("def ")
561            || trimmed.starts_with("async def ")
562            || trimmed.starts_with("class ")
563        {
564            let sig = trimmed.trim_end_matches(':').trim_end();
565            result.push(sig.to_string());
566        }
567    }
568
569    result
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::unit::{AttemptOutcome, AttemptRecord};
576    use std::fs;
577    use tempfile::TempDir;
578
579    fn setup_test_env() -> (TempDir, std::path::PathBuf) {
580        let dir = TempDir::new().unwrap();
581        let mana_dir = dir.path().join(".mana");
582        fs::create_dir(&mana_dir).unwrap();
583        (dir, mana_dir)
584    }
585
586    #[test]
587    fn assemble_context_basic() {
588        let (_dir, mana_dir) = setup_test_env();
589        let mut unit = Unit::new("1", "Test unit");
590        unit.description = Some("A description with no file paths".to_string());
591        let slug = crate::util::title_to_slug(&unit.title);
592        let unit_path = mana_dir.join(format!("1-{}.md", slug));
593        unit.to_file(&unit_path).unwrap();
594
595        let ctx = assemble_agent_context(&mana_dir, "1").unwrap();
596        assert_eq!(ctx.unit.id, "1");
597        assert!(ctx.files.is_empty());
598    }
599
600    #[test]
601    fn assemble_context_with_files() {
602        let (dir, mana_dir) = setup_test_env();
603        let project_dir = dir.path();
604
605        let src_dir = project_dir.join("src");
606        fs::create_dir(&src_dir).unwrap();
607        fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
608
609        let mut unit = Unit::new("1", "Test unit");
610        unit.description = Some("Check src/foo.rs for implementation".to_string());
611        let slug = crate::util::title_to_slug(&unit.title);
612        let unit_path = mana_dir.join(format!("1-{}.md", slug));
613        unit.to_file(&unit_path).unwrap();
614
615        let ctx = assemble_agent_context(&mana_dir, "1").unwrap();
616        assert_eq!(ctx.files.len(), 1);
617        assert_eq!(ctx.files[0].path, "src/foo.rs");
618        assert!(ctx.files[0].content.is_some());
619    }
620
621    #[test]
622    fn assemble_context_not_found() {
623        let (_dir, mana_dir) = setup_test_env();
624        let result = assemble_agent_context(&mana_dir, "999");
625        assert!(result.is_err());
626    }
627
628    #[test]
629    fn load_rules_returns_none_when_missing() {
630        let (_dir, mana_dir) = setup_test_env();
631        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
632        assert!(load_rules(&mana_dir).is_none());
633    }
634
635    #[test]
636    fn load_rules_returns_none_when_empty() {
637        let (_dir, mana_dir) = setup_test_env();
638        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
639        fs::write(mana_dir.join("RULES.md"), "   \n\n  ").unwrap();
640        assert!(load_rules(&mana_dir).is_none());
641    }
642
643    #[test]
644    fn load_rules_returns_content() {
645        let (_dir, mana_dir) = setup_test_env();
646        fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
647        fs::write(mana_dir.join("RULES.md"), "# My Rules\nNo unwrap.\n").unwrap();
648        let result = load_rules(&mana_dir);
649        assert!(result.is_some());
650        assert!(result.unwrap().contains("No unwrap."));
651    }
652
653    #[test]
654    fn format_attempt_notes_empty() {
655        let unit = Unit::new("1", "Empty unit");
656        assert!(format_attempt_notes(&unit).is_none());
657    }
658
659    #[test]
660    fn format_attempt_notes_with_data() {
661        let mut unit = Unit::new("1", "Test unit");
662        unit.attempt_log = vec![AttemptRecord {
663            num: 1,
664            outcome: AttemptOutcome::Abandoned,
665            notes: Some("Tried X, hit bug Y".to_string()),
666            agent: Some("pi-agent".to_string()),
667            started_at: None,
668            finished_at: None,
669            autonomy_observation: None,
670        }];
671
672        let result = format_attempt_notes(&unit).unwrap();
673        assert!(result.contains("Attempt #1"));
674        assert!(result.contains("pi-agent"));
675        assert!(result.contains("abandoned"));
676        assert!(result.contains("Tried X, hit bug Y"));
677    }
678
679    #[test]
680    fn format_attempt_notes_with_unit_notes() {
681        let mut unit = Unit::new("1", "Test unit");
682        unit.notes = Some("Watch out for edge cases".to_string());
683        let result = format_attempt_notes(&unit).unwrap();
684        assert!(result.contains("Watch out for edge cases"));
685        assert!(result.contains("Unit notes:"));
686    }
687
688    #[test]
689    fn format_attempt_notes_skips_whitespace_only() {
690        let mut unit = Unit::new("1", "Test unit");
691        unit.notes = Some("   ".to_string());
692        unit.attempt_log = vec![AttemptRecord {
693            num: 1,
694            outcome: AttemptOutcome::Abandoned,
695            notes: Some("  ".to_string()),
696            agent: None,
697            started_at: None,
698            finished_at: None,
699            autonomy_observation: None,
700        }];
701        assert!(format_attempt_notes(&unit).is_none());
702    }
703
704    #[test]
705    fn summarize_child_units_includes_recent_outcome_summary_and_follow_up() {
706        let (_dir, mana_dir) = setup_test_env();
707
708        let parent = Unit::new("1", "Parent");
709        let parent_slug = crate::util::title_to_slug(&parent.title);
710        parent
711            .to_file(mana_dir.join(format!("1-{}.md", parent_slug)))
712            .unwrap();
713
714        let mut child = Unit::new("1.1", "Child task");
715        child.parent = Some("1".to_string());
716        child.status = Status::Open;
717        child.verify = Some("cargo test child".to_string());
718        child.notes =
719            Some("Investigated parser edge case and found bad separator handling".to_string());
720        child.decisions = vec!["Pick parser boundary behavior".to_string()];
721        child.attempt_log = vec![AttemptRecord {
722            num: 1,
723            outcome: AttemptOutcome::Failed,
724            notes: Some("Attempted fix A but fixture still fails".to_string()),
725            agent: Some("imp".to_string()),
726            started_at: None,
727            finished_at: None,
728            autonomy_observation: None,
729        }];
730        let child_slug = crate::util::title_to_slug(&child.title);
731        child
732            .to_file(mana_dir.join(format!("1.1-{}.md", child_slug)))
733            .unwrap();
734
735        let summaries = summarize_child_units(&mana_dir, "1");
736        assert_eq!(summaries.len(), 1);
737        assert_eq!(summaries[0].id, "1.1");
738        assert_eq!(summaries[0].status, "open");
739        assert_eq!(summaries[0].attempts, 1);
740        assert_eq!(summaries[0].recent_outcome.as_deref(), Some("failed"));
741        assert!(summaries[0]
742            .summary
743            .as_deref()
744            .unwrap()
745            .contains("Investigated parser edge case"));
746        assert_eq!(
747            summaries[0].follow_up.as_deref(),
748            Some("1 unresolved decision(s)")
749        );
750    }
751
752    #[test]
753    fn summarize_child_units_falls_back_to_closed_status_when_no_attempts_exist() {
754        let (_dir, mana_dir) = setup_test_env();
755
756        let parent = Unit::new("2", "Parent");
757        let parent_slug = crate::util::title_to_slug(&parent.title);
758        parent
759            .to_file(mana_dir.join(format!("2-{}.md", parent_slug)))
760            .unwrap();
761
762        let mut child = Unit::new("2.1", "Closed child");
763        child.parent = Some("2".to_string());
764        child.status = Status::Closed;
765        child.close_reason = Some("Completed successfully after consolidation".to_string());
766        let child_slug = crate::util::title_to_slug(&child.title);
767        child
768            .to_file(mana_dir.join(format!("2.1-{}.md", child_slug)))
769            .unwrap();
770
771        let summaries = summarize_child_units(&mana_dir, "2");
772        assert_eq!(summaries.len(), 1);
773        assert_eq!(summaries[0].recent_outcome.as_deref(), Some("success"));
774        assert_eq!(summaries[0].attempts, 0);
775        assert!(summaries[0]
776            .summary
777            .as_deref()
778            .unwrap()
779            .contains("Completed successfully"));
780        assert!(summaries[0].follow_up.is_none());
781    }
782
783    #[test]
784    fn assemble_agent_context_includes_child_summaries() {
785        let (_dir, mana_dir) = setup_test_env();
786
787        let mut parent = Unit::new("3", "Parent");
788        parent.description = Some("Review child outputs".to_string());
789        let parent_slug = crate::util::title_to_slug(&parent.title);
790        parent
791            .to_file(mana_dir.join(format!("3-{}.md", parent_slug)))
792            .unwrap();
793
794        let mut child = Unit::new("3.1", "Child");
795        child.parent = Some("3".to_string());
796        child.status = Status::Closed;
797        child.close_reason = Some("Found root cause and fixed it".to_string());
798        let child_slug = crate::util::title_to_slug(&child.title);
799        child
800            .to_file(mana_dir.join(format!("3.1-{}.md", child_slug)))
801            .unwrap();
802
803        let ctx = assemble_agent_context(&mana_dir, "3").unwrap();
804        assert_eq!(ctx.child_summaries.len(), 1);
805        assert_eq!(ctx.child_summaries[0].id, "3.1");
806        assert_eq!(
807            ctx.child_summaries[0].recent_outcome.as_deref(),
808            Some("success")
809        );
810    }
811
812    #[test]
813    fn merge_paths_deduplicates() {
814        let mut unit = Unit::new("1", "Test unit");
815        unit.paths = vec!["src/main.rs".to_string()];
816        unit.description = Some("Check src/main.rs and src/lib.rs".to_string());
817        let paths = merge_paths(&unit);
818        assert_eq!(paths, vec!["src/main.rs", "src/lib.rs"]);
819    }
820
821    #[test]
822    fn extract_rust_structure_basic() {
823        let content = "use std::io;\n\npub fn hello() {\n}\n\nstruct Foo {\n}\n";
824        let result = extract_file_structure("test.rs", content).unwrap();
825        assert!(result.contains("use std::io;"));
826        assert!(result.contains("pub fn hello()"));
827        assert!(result.contains("struct Foo"));
828    }
829
830    #[test]
831    fn extract_ts_structure_basic() {
832        let content = "import { foo } from 'bar';\n\nexport function hello() {\n}\n";
833        let result = extract_file_structure("test.ts", content).unwrap();
834        assert!(result.contains("import { foo } from 'bar';"));
835        assert!(result.contains("export function hello()"));
836    }
837
838    #[test]
839    fn extract_python_structure_basic() {
840        let content = "import os\n\ndef hello():\n    pass\n";
841        let result = extract_file_structure("test.py", content).unwrap();
842        assert!(result.contains("import os"));
843        assert!(result.contains("def hello()"));
844    }
845}