1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::{
4 fs,
5 path::{Path, PathBuf},
6};
7use walkdir::WalkDir;
8use tracing::debug;
9
10use crate::natspec::extract::{extract_source_comments, SourceComment, SourceItemKind};
11use crate::natspec::{TextRange}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ManifestEntry {
19 pub file_path: PathBuf, pub text: String,
21 pub raw_comment_span: TextRange,
22 pub item_kind: SourceItemKind,
23 pub item_name: Option<String>,
24 pub item_span: TextRange,
25 pub is_natspec: bool,
26}
27
28impl From<(SourceComment, PathBuf)> for ManifestEntry {
29 fn from((sc, file_path): (SourceComment, PathBuf)) -> Self {
30 ManifestEntry {
31 file_path,
32 text: sc.text,
33 raw_comment_span: sc.raw_comment_span,
34 item_kind: sc.item_kind,
35 item_name: sc.item_name,
36 item_span: sc.item_span,
37 is_natspec: sc.is_natspec,
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
43pub struct Manifest {
44 pub entries: Vec<ManifestEntry>,
45 }
47
48impl Manifest {
49 pub fn query_entries(
50 &self,
51 kind: SourceItemKind,
52 name_pattern: Option<&str>,
53 ) -> Vec<&ManifestEntry> {
54 self.entries
55 .iter()
56 .filter(|entry| {
57 entry.item_kind == kind
58 && name_pattern.map_or(true, |pattern| {
59 entry.item_name.as_ref().map_or(false, |name| {
60 name.to_lowercase().contains(&pattern.to_lowercase())
61 })
62 })
63 })
64 .collect()
65 }
66
67 pub fn add_entry(&mut self, entry: ManifestEntry) {
69 self.entries.push(entry);
70 }
71
72 pub fn extend_entries(&mut self, entries: Vec<ManifestEntry>) {
74 self.entries.extend(entries);
75 }
76}
77
78pub fn find_solidity_files_for_manifest(
79 paths: &[PathBuf],
80 project_root: &Path,
81) -> Result<Vec<PathBuf>> {
82 let mut sol_files = Vec::new();
83 for path_arg in paths {
84 let absolute_path_arg = if path_arg.is_absolute() {
85 path_arg.clone()
86 } else {
87 project_root.join(path_arg)
88 };
89
90 if absolute_path_arg.is_dir() {
91 for entry in WalkDir::new(&absolute_path_arg)
92 .into_iter()
93 .filter_map(|e| e.ok())
94 {
95 if entry.file_type().is_file()
96 && entry.path().extension().map_or(false, |ext| ext == "sol")
97 {
98 let relative_path = entry
100 .path()
101 .strip_prefix(project_root)
102 .unwrap_or_else(|_| entry.path()) .to_path_buf();
104 sol_files.push(relative_path);
105 }
106 }
107 } else if absolute_path_arg.is_file()
108 && absolute_path_arg
109 .extension()
110 .map_or(false, |ext| ext == "sol")
111 {
112 let relative_path = absolute_path_arg
113 .strip_prefix(project_root)
114 .unwrap_or_else(|_| &absolute_path_arg) .to_path_buf();
116 sol_files.push(relative_path);
117 } else if absolute_path_arg.is_file() {
118 } else {
120 debug!("Warning: Path not found or invalid: {}", path_arg.display());
122 }
123 }
124 sol_files.sort();
125 sol_files.dedup(); Ok(sol_files)
127}
128
129pub fn generate_manifest(
130 input_paths: &[PathBuf],
131 project_root: &Path,
132 manifest_file_path: &Path,
133) -> Result<Manifest> {
134 let mut manifest = Manifest::default();
135 let sol_files_relative = find_solidity_files_for_manifest(input_paths, project_root)
136 .context("Failed to find Solidity files")?;
137
138 if sol_files_relative.is_empty() {
139 save_manifest(&manifest, manifest_file_path)?;
142 return Ok(manifest);
143 }
144
145 for relative_file_path in &sol_files_relative {
146 let full_file_path = project_root.join(relative_file_path);
147 let source = fs::read_to_string(&full_file_path).with_context(|| {
148 format!(
149 "Failed to read Solidity source file: {}",
150 full_file_path.display()
151 )
152 })?;
153
154 match extract_source_comments(&source) {
155 Ok(source_comments) => {
156 let entries: Vec<ManifestEntry> = source_comments
157 .into_iter()
158 .map(|sc| ManifestEntry::from((sc, relative_file_path.clone())))
159 .collect();
160 manifest.extend_entries(entries);
161 }
162 Err(e) => {
163 debug!(
164 "Warning: Failed to extract comments from {}: {}. Skipping file.",
165 relative_file_path.display(),
166 e
167 );
168 }
169 }
170 }
171
172 save_manifest(&manifest, manifest_file_path)?;
173 Ok(manifest)
174}
175
176pub fn load_manifest(manifest_file_path: &Path) -> Result<Manifest> {
177 let file_content = fs::read_to_string(manifest_file_path).with_context(|| {
178 format!(
179 "Failed to read manifest file: {}",
180 manifest_file_path.display()
181 )
182 })?;
183 let manifest: Manifest = serde_yaml::from_str(&file_content).with_context(|| {
184 format!(
185 "Failed to deserialize manifest from YAML: {}",
186 manifest_file_path.display()
187 )
188 })?;
189 Ok(manifest)
190}
191
192pub fn save_manifest(manifest: &Manifest, manifest_file_path: &Path) -> Result<()> {
193 let yaml_string =
194 serde_yaml::to_string(manifest).context("Failed to serialize manifest to YAML")?;
195
196 if let Some(parent_dir) = manifest_file_path.parent() {
197 fs::create_dir_all(parent_dir).with_context(|| {
198 format!(
199 "Failed to create parent directories for manifest file: {}",
200 parent_dir.display()
201 )
202 })?;
203 }
204
205 fs::write(manifest_file_path, yaml_string).with_context(|| {
206 format!(
207 "Failed to write manifest to file: {}",
208 manifest_file_path.display()
209 )
210 })?;
211 Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use std::fs::File;
218 use std::io::Write;
219 use tempfile::tempdir;
220
221 fn create_test_sol_file(dir: &Path, filename: &str, content: &str) -> PathBuf {
222 let file_path = dir.join(filename);
223 let mut file = File::create(&file_path).unwrap();
224 writeln!(file, "{}", content).unwrap();
225 file_path
226 }
227
228 #[test]
229 fn test_generate_and_load_manifest_empty() -> Result<()> {
230 let tmp_dir = tempdir()?;
231 let project_root = tmp_dir.path();
232 let manifest_path = project_root.join("manifest.yaml");
233 let input_paths: [PathBuf; 0] = [];
234
235 let generated_manifest = generate_manifest(&input_paths, project_root, &manifest_path)?;
236 assert!(generated_manifest.entries.is_empty());
237 assert!(manifest_path.exists());
238
239 let loaded_manifest = load_manifest(&manifest_path)?;
240 assert_eq!(generated_manifest, loaded_manifest);
241 Ok(())
242 }
243
244 #[test]
245 fn test_generate_manifest_single_file() -> Result<()> {
246 let tmp_dir = tempdir()?;
247 let project_root = tmp_dir.path();
248 let contracts_dir = project_root.join("contracts");
249 fs::create_dir_all(&contracts_dir)?;
250
251 let sol_content = r#"
252 /// This is a contract
253 contract MyContract {}
254
255 /// This is a function
256 function myFunction() public {}
257 "#;
258 let sol_file_rel_path = PathBuf::from("contracts/MyContract.sol");
259 create_test_sol_file(
260 project_root,
261 sol_file_rel_path.to_str().unwrap(),
262 sol_content,
263 );
264
265 let manifest_path = project_root.join("manifest.yaml");
266 let input_paths = [PathBuf::from("contracts")]; let generated_manifest = generate_manifest(&input_paths, project_root, &manifest_path)?;
269
270 assert_eq!(generated_manifest.entries.len(), 2);
271 assert!(manifest_path.exists());
272
273 let contract_entry = generated_manifest
274 .entries
275 .iter()
276 .find(|e| e.item_name == Some("MyContract".to_string()))
277 .unwrap();
278 assert_eq!(contract_entry.item_kind, SourceItemKind::Contract);
279 assert_eq!(contract_entry.text, "/// This is a contract");
280 assert_eq!(contract_entry.file_path, sol_file_rel_path);
281
282 let function_entry = generated_manifest
283 .entries
284 .iter()
285 .find(|e| e.item_name == Some("myFunction".to_string()))
286 .unwrap();
287 assert_eq!(function_entry.item_kind, SourceItemKind::Function);
288 assert_eq!(function_entry.text, "/// This is a function");
289 assert_eq!(function_entry.file_path, sol_file_rel_path);
290
291 let loaded_manifest = load_manifest(&manifest_path)?;
292 assert_eq!(generated_manifest, loaded_manifest);
293
294 Ok(())
295 }
296
297 #[test]
298 fn test_find_solidity_files_for_manifest_logic() -> Result<()> {
299 let tmp_dir = tempdir()?;
300 let project_root = tmp_dir.path();
301
302 let src_dir = project_root.join("src");
303 fs::create_dir(&src_dir)?;
304 let interfaces_dir = src_dir.join("interfaces");
305 fs::create_dir(&interfaces_dir)?;
306 let lib_dir = project_root.join("lib");
307 fs::create_dir(&lib_dir)?;
308
309 create_test_sol_file(project_root, "A.sol", "// A");
310 create_test_sol_file(&src_dir, "B.sol", "// B");
311 create_test_sol_file(&interfaces_dir, "C.sol", "// C");
312 create_test_sol_file(&lib_dir, "D.sol", "// D");
313 create_test_sol_file(&src_dir, "E.txt", "// E");
315
316 let paths1 = [
318 PathBuf::from("A.sol"),
319 src_dir
320 .join("B.sol")
321 .strip_prefix(project_root)?
322 .to_path_buf(),
323 ];
324 let files1 = find_solidity_files_for_manifest(&paths1, project_root)?;
325 assert_eq!(files1.len(), 2);
326 assert!(files1.contains(&PathBuf::from("A.sol")));
327 assert!(files1.contains(&PathBuf::from("src/B.sol")));
328
329 let paths2 = [PathBuf::from("src")];
331 let files2 = find_solidity_files_for_manifest(&paths2, project_root)?;
332 assert_eq!(files2.len(), 2); assert!(files2.contains(&PathBuf::from("src/B.sol")));
334 assert!(files2.contains(&PathBuf::from("src/interfaces/C.sol")));
335
336 let paths3 = [
338 PathBuf::from("A.sol"),
339 PathBuf::from("src"),
340 lib_dir.clone(),
341 ];
342 let files3 = find_solidity_files_for_manifest(&paths3, project_root)?;
343 assert_eq!(files3.len(), 4);
344 assert!(files3.contains(&PathBuf::from("A.sol")));
345 assert!(files3.contains(&PathBuf::from("src/B.sol")));
346 assert!(files3.contains(&PathBuf::from("src/interfaces/C.sol")));
347 assert!(files3.contains(&PathBuf::from("lib/D.sol")));
348
349 let paths4 = [PathBuf::from(".")]; let files4 = find_solidity_files_for_manifest(&paths4, project_root)?;
352 assert_eq!(files4.len(), 4); let paths5: [PathBuf; 0] = [];
356 let files5 = find_solidity_files_for_manifest(&paths5, project_root)?;
357 assert!(files5.is_empty());
358
359 let paths6 = [PathBuf::from("src/E.txt")];
361 let files6 = find_solidity_files_for_manifest(&paths6, project_root)?;
362 assert!(files6.is_empty());
363
364 Ok(())
365 }
366
367 #[test]
368 fn test_query_entries() -> Result<()> {
369 let tmp_dir = tempdir()?;
370 let project_root = tmp_dir.path();
371 let manifest_path = project_root.join("manifest.yaml");
372
373 let sol_content1 = r#"
374 /// @title Contract Alpha
375 contract Alpha {}
376 /// @notice A public function
377 function doAlpha() public {}
378 "#;
379 create_test_sol_file(project_root, "Alpha.sol", sol_content1);
380
381 let sol_content2 = r#"
382 /// @title Interface Beta
383 interface Beta {
384 /// @dev A beta function
385 function doBeta() external;
386 }
387 /// @notice Another contract
388 contract Gamma {}
389 "#;
390 create_test_sol_file(project_root, "BetaGamma.sol", sol_content2);
391
392 let input_paths = [PathBuf::from(".")]; let manifest = generate_manifest(&input_paths, project_root, &manifest_path)?;
395
396 let contracts = manifest.query_entries(SourceItemKind::Contract, None);
398 assert_eq!(contracts.len(), 2);
399 assert!(contracts
400 .iter()
401 .any(|e| e.item_name == Some("Alpha".to_string())));
402 assert!(contracts
403 .iter()
404 .any(|e| e.item_name == Some("Gamma".to_string())));
405
406 let alpha_contract = manifest.query_entries(SourceItemKind::Contract, Some("Alpha"));
408 assert_eq!(alpha_contract.len(), 1);
409 assert_eq!(alpha_contract[0].item_name, Some("Alpha".to_string()));
410
411 let ga_contract = manifest.query_entries(SourceItemKind::Contract, Some("gam"));
413 assert_eq!(ga_contract.len(), 1);
414 assert_eq!(ga_contract[0].item_name, Some("Gamma".to_string()));
415
416 let functions = manifest.query_entries(SourceItemKind::Function, None);
418 assert_eq!(functions.len(), 2); assert!(functions
420 .iter()
421 .any(|e| e.item_name == Some("doAlpha".to_string())));
422 assert!(functions
423 .iter()
424 .any(|e| e.item_name == Some("doBeta".to_string())));
425
426 let do_beta_function = manifest.query_entries(SourceItemKind::Function, Some("doBeta"));
428 assert_eq!(do_beta_function.len(), 1);
429 assert_eq!(do_beta_function[0].item_name, Some("doBeta".to_string()));
430 assert_eq!(
431 do_beta_function[0].file_path,
432 PathBuf::from("BetaGamma.sol")
433 );
434
435 let interfaces = manifest.query_entries(SourceItemKind::Interface, None);
437 assert_eq!(interfaces.len(), 1);
438 assert_eq!(interfaces[0].item_name, Some("Beta".to_string()));
439
440 let events = manifest.query_entries(SourceItemKind::Event, None);
442 assert!(events.is_empty());
443
444 let non_existent_func =
446 manifest.query_entries(SourceItemKind::Function, Some("noSuchFunction"));
447 assert!(non_existent_func.is_empty());
448
449 Ok(())
450 }
451}