Skip to main content

agent_launch/
context.rs

1//! Gather repo context: CHANGELOG section, README, recent commits, optional manifest.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use serde::Serialize;
7use serde_json::Value;
8use thiserror::Error;
9
10pub const README_MAX: usize = 2000;
11
12#[derive(Debug, Error)]
13pub enum ContextError {
14    #[error("{0}")]
15    Missing(String),
16    #[error("failed to parse manifest at {path}: {source}")]
17    Manifest {
18        path: String,
19        #[source]
20        source: serde_json::Error,
21    },
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct GatheredContext {
26    pub version: String,
27    pub changelog: String,
28    pub readme: String,
29    pub commits: Vec<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub manifest: Option<Value>,
32}
33
34pub fn gather_context(
35    cwd: &Path,
36    version: &str,
37    manifest_path: Option<&Path>,
38) -> Result<GatheredContext, ContextError> {
39    let changelog_path = cwd.join("CHANGELOG.md");
40    if !changelog_path.exists() {
41        return Err(ContextError::Missing(format!(
42            "CHANGELOG.md not found at {}",
43            changelog_path.display()
44        )));
45    }
46    let changelog_raw = std::fs::read_to_string(&changelog_path)
47        .map_err(|e| ContextError::Missing(format!("failed to read CHANGELOG.md: {e}")))?;
48    let changelog = extract_changelog_section(&changelog_raw, version)?;
49
50    let readme_path = cwd.join("README.md");
51    if !readme_path.exists() {
52        return Err(ContextError::Missing(format!(
53            "README.md not found at {}",
54            readme_path.display()
55        )));
56    }
57    let readme_full = std::fs::read_to_string(&readme_path)
58        .map_err(|e| ContextError::Missing(format!("failed to read README.md: {e}")))?;
59    let readme = if readme_full.len() > README_MAX {
60        // Slice at the README_MAX byte; safe-truncate at char boundary if needed.
61        let mut end = README_MAX;
62        while !readme_full.is_char_boundary(end) && end > 0 {
63            end -= 1;
64        }
65        readme_full[..end].to_string()
66    } else {
67        readme_full
68    };
69
70    let commits = read_recent_commits(cwd, 50);
71
72    let manifest = if let Some(mp) = manifest_path {
73        if !mp.exists() {
74            return Err(ContextError::Missing(format!(
75                "manifest file not found: {}",
76                mp.display()
77            )));
78        }
79        let raw = std::fs::read_to_string(mp)
80            .map_err(|e| ContextError::Missing(format!("failed to read manifest: {e}")))?;
81        let v = serde_json::from_str(&raw).map_err(|source| ContextError::Manifest {
82            path: mp.display().to_string(),
83            source,
84        })?;
85        Some(v)
86    } else {
87        None
88    };
89
90    Ok(GatheredContext {
91        version: version.to_string(),
92        changelog,
93        readme,
94        commits,
95        manifest,
96    })
97}
98
99fn extract_changelog_section(content: &str, version: &str) -> Result<String, ContextError> {
100    // Find the `## [<version>] ...` heading line, then capture lines until the next `## [`
101    // heading or end-of-file. We do this without lookahead since the `regex` crate doesn't
102    // support it.
103    let header = format!("## [{version}]");
104    let mut start: Option<usize> = None;
105    for (i, line) in content.lines().enumerate() {
106        if line.starts_with(&header) {
107            start = Some(i);
108            break;
109        }
110    }
111    let start = start.ok_or_else(|| {
112        ContextError::Missing(format!("version {version} not found in CHANGELOG.md"))
113    })?;
114    let lines: Vec<&str> = content.lines().collect();
115    let mut end = lines.len();
116    for (i, line) in lines.iter().enumerate().skip(start + 1) {
117        if line.starts_with("## [") {
118            end = i;
119            break;
120        }
121    }
122    // Skip the header line itself.
123    let body = lines[start + 1..end].join("\n");
124    Ok(body.trim().to_string())
125}
126
127fn read_recent_commits(cwd: &Path, limit: usize) -> Vec<String> {
128    let out = Command::new("git")
129        .arg("log")
130        .arg("--pretty=format:%h %s")
131        .arg(format!("-{limit}"))
132        .current_dir(cwd)
133        .output();
134    match out {
135        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
136            .lines()
137            .map(|l| l.trim().to_string())
138            .filter(|l| !l.is_empty())
139            .collect(),
140        _ => Vec::new(),
141    }
142}
143
144#[allow(dead_code)]
145fn _path_dummy() -> PathBuf {
146    PathBuf::new()
147}