traverse_graph/
manifest.rs

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}; // Ensure TextRange and TextIndex are available
12
13// Re-defining TextRange and TextIndex here if we don't want to make them public from natspec::mod
14// For now, assuming they are accessible or we might need to duplicate/re-export them.
15// If they are already Serialize/Deserialize, we can use them directly.
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct ManifestEntry {
19    pub file_path: PathBuf, // Relative path to the source file from the project root
20    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    // Could include other metadata like generation timestamp, project name, etc.
46}
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    /// Adds a single entry to the manifest.
68    pub fn add_entry(&mut self, entry: ManifestEntry) {
69        self.entries.push(entry);
70    }
71
72    /// Extends the manifest with multiple entries.
73    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                    // Store path relative to project_root for consistency
99                    let relative_path = entry
100                        .path()
101                        .strip_prefix(project_root)
102                        .unwrap_or_else(|_| entry.path()) // Fallback to absolute if not under root
103                        .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) // Fallback to absolute if not under root
115                .to_path_buf();
116            sol_files.push(relative_path);
117        } else if absolute_path_arg.is_file() {
118            // Log or handle non-Solidity files if necessary
119        } else {
120            // Log or handle non-existent paths
121            debug!("Warning: Path not found or invalid: {}", path_arg.display());
122        }
123    }
124    sol_files.sort();
125    sol_files.dedup(); // Remove duplicates that might arise from overlapping paths
126    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        // It's not an error to find no .sol files, just return an empty manifest
140        // and save it.
141        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")]; // Input is the directory
267
268        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        // Non-solidity file
314        create_test_sol_file(&src_dir, "E.txt", "// E");
315
316        // Test case 1: Specific files
317        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        // Test case 2: Directory
330        let paths2 = [PathBuf::from("src")];
331        let files2 = find_solidity_files_for_manifest(&paths2, project_root)?;
332        assert_eq!(files2.len(), 2); // B.sol, interfaces/C.sol
333        assert!(files2.contains(&PathBuf::from("src/B.sol")));
334        assert!(files2.contains(&PathBuf::from("src/interfaces/C.sol")));
335
336        // Test case 3: Multiple directories and files, some outside project root context for paths
337        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        // Test case 4: Project root itself
350        let paths4 = [PathBuf::from(".")]; // Representing project root
351        let files4 = find_solidity_files_for_manifest(&paths4, project_root)?;
352        assert_eq!(files4.len(), 4); // A.sol, src/B.sol, src/interfaces/C.sol, lib/D.sol
353
354        // Test case 5: Empty input
355        let paths5: [PathBuf; 0] = [];
356        let files5 = find_solidity_files_for_manifest(&paths5, project_root)?;
357        assert!(files5.is_empty());
358
359        // Test case 6: Path to a non-sol file
360        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(".")]; // Scan whole project root
393
394        let manifest = generate_manifest(&input_paths, project_root, &manifest_path)?;
395
396        // Query for all contracts
397        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        // Query for specific contract by name
407        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        // Query for specific contract by partial name (case-insensitive)
412        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        // Query for all functions
417        let functions = manifest.query_entries(SourceItemKind::Function, None);
418        assert_eq!(functions.len(), 2); // doAlpha, doBeta
419        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        // Query for specific function by name
427        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        // Query for interfaces
436        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        // Query for non-existent kind
441        let events = manifest.query_entries(SourceItemKind::Event, None);
442        assert!(events.is_empty());
443
444        // Query for existing kind but non-existent name
445        let non_existent_func =
446            manifest.query_entries(SourceItemKind::Function, Some("noSuchFunction"));
447        assert!(non_existent_func.is_empty());
448
449        Ok(())
450    }
451}