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
15pub 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
26pub struct FileEntry {
28 pub path: String,
29 pub content: Option<String>,
30 pub structure: Option<String>,
31}
32
33#[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
45pub 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
55pub 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
125pub 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
152pub 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
203pub 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
224pub 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
284pub 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
402pub 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
426pub 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}