1use 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 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 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 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}