1use std::collections::HashSet;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6use crate::config::Config;
7use crate::ctx_assembler::{extract_paths, read_file};
8use crate::discovery::find_unit_file;
9use crate::index::Index;
10use crate::unit::{AttemptOutcome, Unit};
11
12pub struct DepProvider {
16 pub artifact: String,
17 pub unit_id: String,
18 pub unit_title: String,
19 pub status: String,
20 pub description: Option<String>,
21}
22
23pub struct FileEntry {
25 pub path: String,
26 pub content: Option<String>,
27 pub structure: Option<String>,
28}
29
30pub struct AgentContext {
32 pub unit: Unit,
33 pub rules: Option<String>,
34 pub attempt_notes: Option<String>,
35 pub dep_providers: Vec<DepProvider>,
36 pub files: Vec<FileEntry>,
37}
38
39pub fn assemble_agent_context(mana_dir: &Path, id: &str) -> Result<AgentContext> {
48 let unit_path =
49 find_unit_file(mana_dir, id).context(format!("Could not find unit with ID: {}", id))?;
50
51 let unit = Unit::from_file(&unit_path).context(format!(
52 "Failed to parse unit from: {}",
53 unit_path.display()
54 ))?;
55
56 let project_dir = mana_dir
57 .parent()
58 .ok_or_else(|| anyhow::anyhow!("Invalid .mana/ path: {}", mana_dir.display()))?;
59
60 let paths = merge_paths(&unit);
61 let rules = load_rules(mana_dir);
62 let attempt_notes = format_attempt_notes(&unit);
63 let dep_providers = resolve_dependency_context(mana_dir, &unit);
64
65 let canonical_base = project_dir
66 .canonicalize()
67 .context("Cannot canonicalize project dir")?;
68
69 let mut files: Vec<FileEntry> = Vec::new();
70 for path_str in &paths {
71 let full_path = project_dir.join(path_str);
72 let canonical = full_path.canonicalize().ok();
73
74 let in_bounds = canonical
75 .as_ref()
76 .map(|c| c.starts_with(&canonical_base))
77 .unwrap_or(false);
78
79 let content = if let Some(ref c) = canonical {
80 if in_bounds {
81 read_file(c).ok()
82 } else {
83 None
84 }
85 } else {
86 None
87 };
88
89 let structure = content
90 .as_deref()
91 .and_then(|c| extract_file_structure(path_str, c));
92
93 files.push(FileEntry {
94 path: path_str.clone(),
95 content,
96 structure,
97 });
98 }
99
100 Ok(AgentContext {
101 unit,
102 rules,
103 attempt_notes,
104 dep_providers,
105 files,
106 })
107}
108
109pub fn load_rules(mana_dir: &Path) -> Option<String> {
115 let config = Config::load(mana_dir).ok()?;
116 let rules_path = config.rules_path(mana_dir);
117
118 let content = std::fs::read_to_string(&rules_path).ok()?;
119 let trimmed = content.trim();
120
121 if trimmed.is_empty() {
122 return None;
123 }
124
125 let line_count = content.lines().count();
126 if line_count > 1000 {
127 eprintln!(
128 "Warning: RULES.md is very large ({} lines). Consider trimming it.",
129 line_count
130 );
131 }
132
133 Some(content)
134}
135
136pub fn format_attempt_notes(unit: &Unit) -> Option<String> {
142 let mut parts: Vec<String> = Vec::new();
143
144 if let Some(ref notes) = unit.notes {
145 let trimmed = notes.trim();
146 if !trimmed.is_empty() {
147 parts.push(format!("Unit notes:\n{}", trimmed));
148 }
149 }
150
151 let attempt_entries: Vec<String> = unit
152 .attempt_log
153 .iter()
154 .filter_map(|a| {
155 let notes = a.notes.as_deref()?.trim();
156 if notes.is_empty() {
157 return None;
158 }
159 let outcome = match a.outcome {
160 AttemptOutcome::Success => "success",
161 AttemptOutcome::Failed => "failed",
162 AttemptOutcome::Abandoned => "abandoned",
163 };
164 let agent_str = a
165 .agent
166 .as_deref()
167 .map(|ag| format!(" ({})", ag))
168 .unwrap_or_default();
169 Some(format!(
170 "Attempt #{}{} [{}]: {}",
171 a.num, agent_str, outcome, notes
172 ))
173 })
174 .collect();
175
176 if !attempt_entries.is_empty() {
177 parts.push(attempt_entries.join("\n"));
178 }
179
180 if parts.is_empty() {
181 return None;
182 }
183
184 Some(parts.join("\n\n"))
185}
186
187pub fn resolve_dependency_context(mana_dir: &Path, unit: &Unit) -> Vec<DepProvider> {
192 if unit.requires.is_empty() {
193 return Vec::new();
194 }
195
196 let index = match Index::load_or_rebuild(mana_dir) {
197 Ok(idx) => idx,
198 Err(_) => return Vec::new(),
199 };
200
201 let mut providers = Vec::new();
202
203 for required in &unit.requires {
204 let producer = index
205 .units
206 .iter()
207 .find(|e| e.id != unit.id && e.parent == unit.parent && e.produces.contains(required));
208
209 if let Some(entry) = producer {
210 let desc = find_unit_file(mana_dir, &entry.id)
211 .ok()
212 .and_then(|p| Unit::from_file(&p).ok())
213 .and_then(|b| b.description.clone());
214
215 providers.push(DepProvider {
216 artifact: required.clone(),
217 unit_id: entry.id.clone(),
218 unit_title: entry.title.clone(),
219 status: format!("{}", entry.status),
220 description: desc,
221 });
222 }
223 }
224
225 providers
226}
227
228pub fn merge_paths(unit: &Unit) -> Vec<String> {
233 let mut seen = HashSet::new();
234 let mut result = Vec::new();
235
236 for p in &unit.paths {
237 if seen.insert(p.clone()) {
238 result.push(p.clone());
239 }
240 }
241
242 let description = unit.description.as_deref().unwrap_or("");
243 for p in extract_paths(description) {
244 if seen.insert(p.clone()) {
245 result.push(p);
246 }
247 }
248
249 result
250}
251
252pub fn extract_file_structure(path: &str, content: &str) -> Option<String> {
259 let ext = Path::new(path).extension()?.to_str()?;
260
261 let lines: Vec<String> = match ext {
262 "rs" => extract_rust_structure(content),
263 "ts" | "tsx" => extract_ts_structure(content),
264 "py" => extract_python_structure(content),
265 _ => return None,
266 };
267
268 if lines.is_empty() {
269 return None;
270 }
271
272 Some(lines.join("\n"))
273}
274
275fn extract_rust_structure(content: &str) -> Vec<String> {
276 let mut result = Vec::new();
277
278 for line in content.lines() {
279 let trimmed = line.trim();
280
281 if trimmed.is_empty()
282 || trimmed.starts_with("//")
283 || trimmed.starts_with("/*")
284 || trimmed.starts_with('*')
285 {
286 continue;
287 }
288
289 if trimmed.starts_with("use ") {
290 result.push(trimmed.to_string());
291 continue;
292 }
293
294 let is_decl = trimmed.starts_with("pub fn ")
295 || trimmed.starts_with("pub async fn ")
296 || trimmed.starts_with("pub(crate) fn ")
297 || trimmed.starts_with("pub(crate) async fn ")
298 || trimmed.starts_with("fn ")
299 || trimmed.starts_with("async fn ")
300 || trimmed.starts_with("pub struct ")
301 || trimmed.starts_with("pub(crate) struct ")
302 || trimmed.starts_with("struct ")
303 || trimmed.starts_with("pub enum ")
304 || trimmed.starts_with("pub(crate) enum ")
305 || trimmed.starts_with("enum ")
306 || trimmed.starts_with("pub trait ")
307 || trimmed.starts_with("pub(crate) trait ")
308 || trimmed.starts_with("trait ")
309 || trimmed.starts_with("pub type ")
310 || trimmed.starts_with("type ")
311 || trimmed.starts_with("impl ")
312 || trimmed.starts_with("pub const ")
313 || trimmed.starts_with("pub(crate) const ")
314 || trimmed.starts_with("const ")
315 || trimmed.starts_with("pub static ")
316 || trimmed.starts_with("static ");
317
318 if is_decl {
319 let sig = trimmed.trim_end_matches('{').trim_end();
320 result.push(sig.to_string());
321 }
322 }
323
324 result
325}
326
327fn extract_ts_structure(content: &str) -> Vec<String> {
328 let mut result = Vec::new();
329
330 for line in content.lines() {
331 let trimmed = line.trim();
332
333 if trimmed.is_empty()
334 || trimmed.starts_with("//")
335 || trimmed.starts_with("/*")
336 || trimmed.starts_with('*')
337 {
338 continue;
339 }
340
341 if trimmed.starts_with("import ") {
342 result.push(trimmed.to_string());
343 continue;
344 }
345
346 let is_decl = trimmed.starts_with("export function ")
347 || trimmed.starts_with("export async function ")
348 || trimmed.starts_with("export default function ")
349 || trimmed.starts_with("function ")
350 || trimmed.starts_with("async function ")
351 || trimmed.starts_with("export class ")
352 || trimmed.starts_with("export abstract class ")
353 || trimmed.starts_with("class ")
354 || trimmed.starts_with("export interface ")
355 || trimmed.starts_with("interface ")
356 || trimmed.starts_with("export type ")
357 || trimmed.starts_with("export enum ")
358 || trimmed.starts_with("export const ")
359 || trimmed.starts_with("export default class ")
360 || trimmed.starts_with("export default async function ");
361
362 if is_decl {
363 let sig = trimmed.trim_end_matches('{').trim_end();
364 result.push(sig.to_string());
365 }
366 }
367
368 result
369}
370
371fn extract_python_structure(content: &str) -> Vec<String> {
372 let mut result = Vec::new();
373
374 for line in content.lines() {
375 let trimmed = line.trim();
376
377 if trimmed.is_empty() || trimmed.starts_with('#') {
378 continue;
379 }
380
381 if line.starts_with("import ") || line.starts_with("from ") {
382 result.push(trimmed.to_string());
383 continue;
384 }
385
386 if trimmed.starts_with("def ")
387 || trimmed.starts_with("async def ")
388 || trimmed.starts_with("class ")
389 {
390 let sig = trimmed.trim_end_matches(':').trim_end();
391 result.push(sig.to_string());
392 }
393 }
394
395 result
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::unit::{AttemptOutcome, AttemptRecord};
402 use std::fs;
403 use tempfile::TempDir;
404
405 fn setup_test_env() -> (TempDir, std::path::PathBuf) {
406 let dir = TempDir::new().unwrap();
407 let mana_dir = dir.path().join(".mana");
408 fs::create_dir(&mana_dir).unwrap();
409 (dir, mana_dir)
410 }
411
412 #[test]
413 fn assemble_context_basic() {
414 let (_dir, mana_dir) = setup_test_env();
415 let mut unit = Unit::new("1", "Test unit");
416 unit.description = Some("A description with no file paths".to_string());
417 let slug = crate::util::title_to_slug(&unit.title);
418 let unit_path = mana_dir.join(format!("1-{}.md", slug));
419 unit.to_file(&unit_path).unwrap();
420
421 let ctx = assemble_agent_context(&mana_dir, "1").unwrap();
422 assert_eq!(ctx.unit.id, "1");
423 assert!(ctx.files.is_empty());
424 }
425
426 #[test]
427 fn assemble_context_with_files() {
428 let (dir, mana_dir) = setup_test_env();
429 let project_dir = dir.path();
430
431 let src_dir = project_dir.join("src");
432 fs::create_dir(&src_dir).unwrap();
433 fs::write(src_dir.join("foo.rs"), "fn main() {}").unwrap();
434
435 let mut unit = Unit::new("1", "Test unit");
436 unit.description = Some("Check src/foo.rs for implementation".to_string());
437 let slug = crate::util::title_to_slug(&unit.title);
438 let unit_path = mana_dir.join(format!("1-{}.md", slug));
439 unit.to_file(&unit_path).unwrap();
440
441 let ctx = assemble_agent_context(&mana_dir, "1").unwrap();
442 assert_eq!(ctx.files.len(), 1);
443 assert_eq!(ctx.files[0].path, "src/foo.rs");
444 assert!(ctx.files[0].content.is_some());
445 }
446
447 #[test]
448 fn assemble_context_not_found() {
449 let (_dir, mana_dir) = setup_test_env();
450 let result = assemble_agent_context(&mana_dir, "999");
451 assert!(result.is_err());
452 }
453
454 #[test]
455 fn load_rules_returns_none_when_missing() {
456 let (_dir, mana_dir) = setup_test_env();
457 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
458 assert!(load_rules(&mana_dir).is_none());
459 }
460
461 #[test]
462 fn load_rules_returns_none_when_empty() {
463 let (_dir, mana_dir) = setup_test_env();
464 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
465 fs::write(mana_dir.join("RULES.md"), " \n\n ").unwrap();
466 assert!(load_rules(&mana_dir).is_none());
467 }
468
469 #[test]
470 fn load_rules_returns_content() {
471 let (_dir, mana_dir) = setup_test_env();
472 fs::write(mana_dir.join("config.yaml"), "project: test\nnext_id: 1\n").unwrap();
473 fs::write(mana_dir.join("RULES.md"), "# My Rules\nNo unwrap.\n").unwrap();
474 let result = load_rules(&mana_dir);
475 assert!(result.is_some());
476 assert!(result.unwrap().contains("No unwrap."));
477 }
478
479 #[test]
480 fn format_attempt_notes_empty() {
481 let unit = Unit::new("1", "Empty unit");
482 assert!(format_attempt_notes(&unit).is_none());
483 }
484
485 #[test]
486 fn format_attempt_notes_with_data() {
487 let mut unit = Unit::new("1", "Test unit");
488 unit.attempt_log = vec![AttemptRecord {
489 num: 1,
490 outcome: AttemptOutcome::Abandoned,
491 notes: Some("Tried X, hit bug Y".to_string()),
492 agent: Some("pi-agent".to_string()),
493 started_at: None,
494 finished_at: None,
495 }];
496
497 let result = format_attempt_notes(&unit).unwrap();
498 assert!(result.contains("Attempt #1"));
499 assert!(result.contains("pi-agent"));
500 assert!(result.contains("abandoned"));
501 assert!(result.contains("Tried X, hit bug Y"));
502 }
503
504 #[test]
505 fn format_attempt_notes_with_unit_notes() {
506 let mut unit = Unit::new("1", "Test unit");
507 unit.notes = Some("Watch out for edge cases".to_string());
508 let result = format_attempt_notes(&unit).unwrap();
509 assert!(result.contains("Watch out for edge cases"));
510 assert!(result.contains("Unit notes:"));
511 }
512
513 #[test]
514 fn format_attempt_notes_skips_whitespace_only() {
515 let mut unit = Unit::new("1", "Test unit");
516 unit.notes = Some(" ".to_string());
517 unit.attempt_log = vec![AttemptRecord {
518 num: 1,
519 outcome: AttemptOutcome::Abandoned,
520 notes: Some(" ".to_string()),
521 agent: None,
522 started_at: None,
523 finished_at: None,
524 }];
525 assert!(format_attempt_notes(&unit).is_none());
526 }
527
528 #[test]
529 fn merge_paths_deduplicates() {
530 let mut unit = Unit::new("1", "Test unit");
531 unit.paths = vec!["src/main.rs".to_string()];
532 unit.description = Some("Check src/main.rs and src/lib.rs".to_string());
533 let paths = merge_paths(&unit);
534 assert_eq!(paths, vec!["src/main.rs", "src/lib.rs"]);
535 }
536
537 #[test]
538 fn extract_rust_structure_basic() {
539 let content = "use std::io;\n\npub fn hello() {\n}\n\nstruct Foo {\n}\n";
540 let result = extract_file_structure("test.rs", content).unwrap();
541 assert!(result.contains("use std::io;"));
542 assert!(result.contains("pub fn hello()"));
543 assert!(result.contains("struct Foo"));
544 }
545
546 #[test]
547 fn extract_ts_structure_basic() {
548 let content = "import { foo } from 'bar';\n\nexport function hello() {\n}\n";
549 let result = extract_file_structure("test.ts", content).unwrap();
550 assert!(result.contains("import { foo } from 'bar';"));
551 assert!(result.contains("export function hello()"));
552 }
553
554 #[test]
555 fn extract_python_structure_basic() {
556 let content = "import os\n\ndef hello():\n pass\n";
557 let result = extract_file_structure("test.py", content).unwrap();
558 assert!(result.contains("import os"));
559 assert!(result.contains("def hello()"));
560 }
561}