Skip to main content

mana_core/ops/
context.rs

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
12// ─── Result types ────────────────────────────────────────────────────────────
13
14/// Information about a sibling unit that produces an artifact this unit requires.
15pub 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
23/// A file referenced by the unit with its content and structural summary.
24pub struct FileEntry {
25    pub path: String,
26    pub content: Option<String>,
27    pub structure: Option<String>,
28}
29
30/// Fully assembled agent context for a unit.
31pub 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
39// ─── Core operations ─────────────────────────────────────────────────────────
40
41/// Assemble full agent context for a unit — the structured data needed
42/// to build any output format (text, JSON, agent prompt).
43///
44/// Loads the unit, resolves dependency context, merges file paths from
45/// explicit `unit.paths` and regex-extracted paths from the description,
46/// reads file contents, and extracts structural summaries.
47pub 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
109// ─── Rules loading ───────────────────────────────────────────────────────────
110
111/// Load project rules from the configured rules file.
112///
113/// Returns `None` if the file doesn't exist or is empty.
114pub 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
136// ─── Attempt notes ───────────────────────────────────────────────────────────
137
138/// Format the attempt_log and notes field into a combined notes string.
139///
140/// Returns `None` if there are no attempt notes and no unit notes.
141pub 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
187// ─── Dependency context ──────────────────────────────────────────────────────
188
189/// Resolve dependency context: find sibling units that produce artifacts
190/// this unit requires, and load their descriptions.
191pub 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
228// ─── Path merging ────────────────────────────────────────────────────────────
229
230/// Merge explicit `unit.paths` with paths regex-extracted from the description.
231/// Explicit paths come first, then regex-extracted paths fill gaps.
232pub 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
252// ─── Structure extraction ────────────────────────────────────────────────────
253
254/// Extract a structural summary (signatures, imports) from file content.
255///
256/// Dispatches to language-specific extractors based on file extension.
257/// Returns `None` for unrecognized file types or when no structure is found.
258pub 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}