Skip to main content

weavatrix_rust/analyzer/
mod.rs

1//! Repository scan, extraction, resolution, and snapshot orchestration.
2
3mod imports;
4mod mounts;
5mod pipeline;
6mod references;
7mod state;
8mod support;
9
10use crate::language::LanguageRegistry;
11use crate::model::{Result, Snapshot};
12use pipeline::parse_parallel;
13use state::{AnalysisState, parse_source};
14use std::path::Path;
15use support::{canonical_repository, capabilities};
16
17#[derive(Debug, Clone)]
18pub struct AnalyzerConfig {
19    pub max_file_bytes: u64,
20    /// n8n JSON and Dify YAML exports can exceed the general file cap; oversized non-export files
21    /// still stops at `max_file_bytes`.
22    pub n8n_max_file_bytes: u64,
23}
24
25impl Default for AnalyzerConfig {
26    fn default() -> Self {
27        Self {
28            max_file_bytes: crate::language::N8N_DEFAULT_FILE_BYTES,
29            n8n_max_file_bytes: 16 * 1024 * 1024,
30        }
31    }
32}
33
34pub struct Analyzer {
35    config: AnalyzerConfig,
36    languages: LanguageRegistry,
37}
38
39#[derive(Debug, Clone)]
40pub struct SourceInput {
41    pub path: String,
42    pub bytes: Vec<u8>,
43    pub content_hash: Option<String>,
44}
45
46impl Default for Analyzer {
47    fn default() -> Self {
48        Self::new(AnalyzerConfig::default())
49    }
50}
51
52impl Analyzer {
53    #[must_use]
54    pub fn new(config: AnalyzerConfig) -> Self {
55        Self {
56            config,
57            languages: LanguageRegistry::default(),
58        }
59    }
60
61    #[must_use]
62    pub fn supports_path(&self, path: &str) -> bool {
63        Path::new(path)
64            .extension()
65            .and_then(|value| value.to_str())
66            .map(str::to_ascii_lowercase)
67            .is_some_and(|extension| self.languages.adapter_for_extension(&extension).is_some())
68    }
69
70    #[must_use]
71    pub const fn max_file_bytes(&self) -> u64 {
72        self.config.max_file_bytes
73    }
74
75    /// Analyzes a repository into a deterministic, evidence-carrying snapshot.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error when the repository cannot be read, an adapter cannot
80    /// initialize, or normalized facts violate graph integrity.
81    pub fn analyze(&self, repository: impl AsRef<Path>) -> Result<Snapshot> {
82        self.analyze_with_report(repository)
83            .map(|(snapshot, _)| snapshot)
84    }
85
86    /// Analyzes an immutable set of source blobs without materializing a tree.
87    ///
88    /// This is the bridge used by the Git module for revision-aware graph
89    /// comparisons. The repository path only supplies stable repository
90    /// identity; every analyzed byte comes from `sources`.
91    ///
92    /// # Errors
93    ///
94    /// Returns parser or graph validation failures.
95    pub fn analyze_sources(
96        &self,
97        repository: impl AsRef<Path>,
98        revision: impl Into<String>,
99        sources: impl IntoIterator<Item = SourceInput>,
100    ) -> Result<Snapshot> {
101        let repository = canonical_repository(repository.as_ref())?;
102        let sources = sources
103            .into_iter()
104            .filter(|source| admits_source(&source.path, &source.bytes, &self.config))
105            .collect::<Vec<_>>();
106        let mut parsed = parse_parallel(sources.len(), |index| {
107            let source = &sources[index];
108            parse_source(
109                &source.path,
110                &source.bytes,
111                source.content_hash.as_deref(),
112                &self.languages,
113            )
114        })?;
115        mounts::apply(&mut parsed);
116        let (node_hint, edge_hint) = AnalysisState::expected(&parsed);
117        let mut state = AnalysisState::with_capacity(&repository, node_hint, edge_hint)?;
118        for item in parsed {
119            state.integrate(item)?;
120        }
121        state.resolve_references()?;
122        state.into_snapshot(&repository, revision.into(), capabilities(&self.languages))
123    }
124
125    /// Analyzes a repository and serializes the snapshot as JSON.
126    ///
127    /// # Errors
128    ///
129    /// Returns any analysis error or a JSON serialization error.
130    pub fn analyze_json(&self, repository: impl AsRef<Path>, pretty: bool) -> Result<String> {
131        let snapshot = self.analyze(repository)?;
132        if pretty {
133            Ok(blazingly_json::to_string_pretty(&snapshot)?)
134        } else {
135            Ok(blazingly_json::to_string(&snapshot)?)
136        }
137    }
138
139    /// Analyzes a repository and serializes a JS Weavatrix-compatible graph.
140    ///
141    /// # Errors
142    ///
143    /// Returns any analysis error or a JSON serialization error.
144    pub fn analyze_legacy_json(
145        &self,
146        repository: impl AsRef<Path>,
147        pretty: bool,
148    ) -> Result<String> {
149        Ok(self.analyze(repository)?.legacy_json(pretty)?)
150    }
151}
152
153fn admits_source(path: &str, bytes: &[u8], config: &AnalyzerConfig) -> bool {
154    let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
155    if size <= config.max_file_bytes {
156        return true;
157    }
158    if size > config.n8n_max_file_bytes {
159        return false;
160    }
161    let Ok(text) = std::str::from_utf8(bytes) else {
162        return false;
163    };
164    let extension = Path::new(path)
165        .extension()
166        .and_then(|value| value.to_str())
167        .unwrap_or("");
168    if extension.eq_ignore_ascii_case("json") {
169        crate::language::n8n_looks_promising(text)
170            || crate::language::agent_looks_promising(path, text)
171    } else if extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") {
172        crate::language::dify_looks_promising(text)
173    } else if extension.eq_ignore_ascii_case("md") || extension.eq_ignore_ascii_case("mdx") {
174        crate::language::agent_looks_promising(path, text)
175            || crate::language::mermaid_looks_promising(path, text)
176    } else if extension.eq_ignore_ascii_case("mmd") || extension.eq_ignore_ascii_case("mermaid") {
177        crate::language::mermaid_looks_promising(path, text)
178    } else {
179        false
180    }
181}