1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10 architecture::{ArchitectureBoundaryPolicy, ArchitectureGuardReport},
11 errors::IntegrationError,
12 plugin::{PluginScanReport, PluginScanner},
13 plugin_ir::{
14 BridgeSupportMatrix, PluginActivationInventoryEntry, PluginActivationPlan, PluginIR,
15 PluginSetupReadinessContext, PluginTranslationReport, PluginTranslator,
16 },
17};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct CodebaseAwarenessConfig {
21 pub roots: Vec<String>,
22 pub plugin_roots: Vec<String>,
23 pub proposed_mutations: Vec<String>,
24 pub architecture_policy: ArchitectureBoundaryPolicy,
25}
26
27impl Default for CodebaseAwarenessConfig {
28 fn default() -> Self {
29 Self {
30 roots: vec![".".to_owned()],
31 plugin_roots: Vec::new(),
32 proposed_mutations: Vec::new(),
33 architecture_policy: ArchitectureBoundaryPolicy::default(),
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
39pub struct CodebaseAwarenessSnapshot {
40 pub scanned_roots: Vec<String>,
41 pub scanned_files: usize,
42 pub language_distribution: BTreeMap<String, usize>,
43 pub deterministic_fingerprint: String,
44 pub plugin_scan_reports: Vec<PluginScanReport>,
45 pub plugin_translation_reports: Vec<PluginTranslationReport>,
46 pub plugin_activation_reports: Vec<PluginActivationPlan>,
47 pub plugin_inventory: Vec<PluginIR>,
48 pub plugin_activation_inventory: Vec<PluginActivationInventoryEntry>,
49 pub architecture_guard: ArchitectureGuardReport,
50}
51
52#[derive(Debug, Clone)]
53struct FileFact {
54 path: String,
55 size_bytes: u64,
56 language: String,
57}
58
59#[derive(Debug, Default)]
60pub struct CodebaseAwarenessEngine {
61 plugin_scanner: PluginScanner,
62 plugin_translator: PluginTranslator,
63}
64
65impl CodebaseAwarenessEngine {
66 #[must_use]
67 pub fn new() -> Self {
68 Self {
69 plugin_scanner: PluginScanner::new(),
70 plugin_translator: PluginTranslator::new(),
71 }
72 }
73
74 pub fn snapshot(
75 &self,
76 config: &CodebaseAwarenessConfig,
77 ) -> Result<CodebaseAwarenessSnapshot, IntegrationError> {
78 let roots = if config.roots.is_empty() {
79 vec![".".to_owned()]
80 } else {
81 config.roots.clone()
82 };
83
84 let mut file_facts = Vec::new();
85 for root in &roots {
86 let root_path = PathBuf::from(root);
87 if !root_path.exists() {
88 return Err(IntegrationError::AwarenessRootNotFound(root.to_owned()));
89 }
90 collect_file_facts(&root_path, &mut file_facts)?;
91 }
92
93 file_facts.sort_by(|left, right| left.path.cmp(&right.path));
94
95 let mut language_distribution = BTreeMap::new();
96 for fact in &file_facts {
97 *language_distribution
98 .entry(fact.language.clone())
99 .or_insert(0) += 1;
100 }
101
102 let plugin_roots = if config.plugin_roots.is_empty() {
103 roots.clone()
104 } else {
105 config.plugin_roots.clone()
106 };
107
108 let mut plugin_scan_reports = Vec::new();
109 let mut plugin_translation_reports = Vec::new();
110 let mut plugin_activation_reports = Vec::new();
111 let mut plugin_inventory = Vec::new();
112 let mut plugin_activation_inventory = Vec::new();
113 let bridge_matrix = BridgeSupportMatrix::default();
114 let setup_readiness_context = PluginSetupReadinessContext::default();
115
116 for root in &plugin_roots {
117 let report = self.plugin_scanner.scan_path(root)?;
118 let translation = self.plugin_translator.translate_scan_report(&report);
119 let activation = self.plugin_translator.plan_activation(
120 &translation,
121 &bridge_matrix,
122 &setup_readiness_context,
123 );
124 plugin_inventory.extend(translation.entries.iter().cloned());
125 plugin_activation_inventory.extend(activation.inventory_entries(&translation));
126 plugin_scan_reports.push(report);
127 plugin_translation_reports.push(translation);
128 plugin_activation_reports.push(activation);
129 }
130
131 let architecture_guard = config
132 .architecture_policy
133 .evaluate_paths(&config.proposed_mutations);
134
135 Ok(CodebaseAwarenessSnapshot {
136 scanned_roots: roots,
137 scanned_files: file_facts.len(),
138 language_distribution,
139 deterministic_fingerprint: fingerprint(&file_facts),
140 plugin_scan_reports,
141 plugin_translation_reports,
142 plugin_activation_reports,
143 plugin_inventory,
144 plugin_activation_inventory,
145 architecture_guard,
146 })
147 }
148}
149
150fn collect_file_facts(path: &Path, acc: &mut Vec<FileFact>) -> Result<(), IntegrationError> {
151 let metadata = fs::metadata(path).map_err(|error| IntegrationError::AwarenessFileRead {
152 path: path.display().to_string(),
153 reason: error.to_string(),
154 })?;
155
156 if metadata.is_file() {
157 let normalized_path = normalize_path(path);
158 acc.push(FileFact {
159 language: detect_language(path),
160 path: normalized_path,
161 size_bytes: metadata.len(),
162 });
163 return Ok(());
164 }
165
166 for entry in fs::read_dir(path).map_err(|error| IntegrationError::AwarenessFileRead {
167 path: path.display().to_string(),
168 reason: error.to_string(),
169 })? {
170 let entry = entry.map_err(|error| IntegrationError::AwarenessFileRead {
171 path: path.display().to_string(),
172 reason: error.to_string(),
173 })?;
174 let child = entry.path();
175
176 if child.is_dir() {
177 if should_skip_dir(&child) {
178 continue;
179 }
180 collect_file_facts(&child, acc)?;
181 } else if child.is_file() {
182 let child_metadata =
183 fs::metadata(&child).map_err(|error| IntegrationError::AwarenessFileRead {
184 path: child.display().to_string(),
185 reason: error.to_string(),
186 })?;
187 acc.push(FileFact {
188 language: detect_language(&child),
189 path: normalize_path(&child),
190 size_bytes: child_metadata.len(),
191 });
192 }
193 }
194
195 Ok(())
196}
197
198fn detect_language(path: &Path) -> String {
199 let extension = path
200 .extension()
201 .and_then(|ext| ext.to_str())
202 .map(|ext| ext.to_ascii_lowercase())
203 .unwrap_or_else(|| "unknown".to_owned());
204
205 match extension.as_str() {
206 "rs" => "rust".to_owned(),
207 "py" => "python".to_owned(),
208 "go" => "go".to_owned(),
209 "js" => "javascript".to_owned(),
210 "ts" => "typescript".to_owned(),
211 "toml" => "toml".to_owned(),
212 "md" => "markdown".to_owned(),
213 "json" => "json".to_owned(),
214 "yaml" | "yml" => "yaml".to_owned(),
215 other => other.to_owned(),
216 }
217}
218
219fn normalize_path(path: &Path) -> String {
220 path.display()
221 .to_string()
222 .replace('\\', "/")
223 .trim_start_matches("./")
224 .to_owned()
225}
226
227fn should_skip_dir(path: &Path) -> bool {
228 matches!(
229 path.file_name().and_then(|name| name.to_str()),
230 Some(".git" | "target" | "node_modules" | ".venv" | ".idea" | ".codex")
231 )
232}
233
234fn fingerprint(file_facts: &[FileFact]) -> String {
235 const OFFSET_BASIS: u64 = 0xcbf29ce484222325;
236 const PRIME: u64 = 0x100000001b3;
237
238 let mut hash = OFFSET_BASIS;
239 for fact in file_facts {
240 let row = format!("{}:{}:{}\n", fact.path, fact.size_bytes, fact.language);
241 for byte in row.bytes() {
242 hash ^= u64::from(byte);
243 hash = hash.wrapping_mul(PRIME);
244 }
245 }
246 format!("{hash:016x}")
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::time::{SystemTime, UNIX_EPOCH};
253
254 fn unique_tmp_dir(prefix: &str) -> PathBuf {
255 let nanos = SystemTime::now()
256 .duration_since(UNIX_EPOCH)
257 .expect("clock should be monotonic")
258 .as_nanos();
259 std::env::temp_dir().join(format!("{}-{}", prefix, nanos))
260 }
261
262 #[test]
263 fn awareness_snapshot_captures_languages_plugins_and_guard() {
264 let root = unique_tmp_dir("loong-awareness");
265 fs::create_dir_all(&root).expect("create temp root");
266
267 fs::write(root.join("runtime.rs"), "pub fn run() {}\n").expect("write rust file");
268 fs::write(root.join("agent.py"), "print('hello')\n").expect("write python file");
269 fs::write(
270 root.join("plugin.rs"),
271 r#"
272// LOONG_PLUGIN_START
273// {
274// "plugin_id": "openrouter-rs",
275// "provider_id": "openrouter",
276// "connector_name": "openrouter",
277// "channel_id": "primary",
278// "endpoint": "https://openrouter.ai/api/v1/chat/completions",
279// "capabilities": ["InvokeConnector"],
280// "metadata": {"version":"0.5.0"}
281// }
282// LOONG_PLUGIN_END
283"#,
284 )
285 .expect("write plugin file");
286
287 let engine = CodebaseAwarenessEngine::new();
288 let snapshot = engine
289 .snapshot(&CodebaseAwarenessConfig {
290 roots: vec![root.display().to_string()],
291 plugin_roots: vec![root.display().to_string()],
292 proposed_mutations: vec!["examples/spec/runtime-extension.json".to_owned()],
293 architecture_policy: ArchitectureBoundaryPolicy::default(),
294 })
295 .expect("awareness snapshot should succeed");
296
297 assert_eq!(snapshot.scanned_roots.len(), 1);
298 assert_eq!(snapshot.plugin_inventory.len(), 1);
299 assert_eq!(snapshot.plugin_activation_reports.len(), 1);
300 assert_eq!(snapshot.plugin_activation_inventory.len(), 1);
301 assert_eq!(
302 snapshot.plugin_activation_inventory[0]
303 .activation_status
304 .map(|status| status.as_str().to_owned()),
305 Some("ready".to_owned())
306 );
307 assert!(
308 snapshot
309 .language_distribution
310 .get("rust")
311 .copied()
312 .unwrap_or(0)
313 >= 1
314 );
315 assert!(
316 snapshot
317 .language_distribution
318 .get("python")
319 .copied()
320 .unwrap_or(0)
321 >= 1
322 );
323 assert!(!snapshot.architecture_guard.has_denials());
324 assert!(!snapshot.deterministic_fingerprint.is_empty());
325 }
326
327 #[test]
328 fn awareness_snapshot_detects_guard_violations() {
329 let root = unique_tmp_dir("loong-awareness-guard");
330 fs::create_dir_all(&root).expect("create temp root");
331 fs::write(root.join("main.rs"), "fn main() {}\n").expect("write rust file");
332
333 let engine = CodebaseAwarenessEngine::new();
334 let snapshot = engine
335 .snapshot(&CodebaseAwarenessConfig {
336 roots: vec![root.display().to_string()],
337 plugin_roots: Vec::new(),
338 proposed_mutations: vec!["crates/kernel/src/kernel.rs".to_owned()],
339 architecture_policy: ArchitectureBoundaryPolicy::default(),
340 })
341 .expect("awareness snapshot should succeed");
342
343 assert!(snapshot.architecture_guard.has_denials());
344 assert!(
345 snapshot
346 .architecture_guard
347 .denied_paths
348 .contains(&"crates/kernel/src/kernel.rs".to_owned())
349 );
350 }
351
352 #[test]
353 fn awareness_snapshot_skips_target_directory_noise() {
354 let root = unique_tmp_dir("loong-awareness-skip");
355 fs::create_dir_all(root.join("target")).expect("create target directory");
356 fs::write(root.join("target").join("build.bin"), [0_u8, 159, 146, 150])
357 .expect("write binary");
358 fs::write(root.join("lib.rs"), "pub fn stable() {}\n").expect("write rust file");
359
360 let engine = CodebaseAwarenessEngine::new();
361 let snapshot = engine
362 .snapshot(&CodebaseAwarenessConfig {
363 roots: vec![root.display().to_string()],
364 plugin_roots: vec![root.display().to_string()],
365 proposed_mutations: Vec::new(),
366 architecture_policy: ArchitectureBoundaryPolicy::default(),
367 })
368 .expect("awareness snapshot should succeed");
369
370 assert_eq!(snapshot.scanned_files, 1);
371 assert_eq!(snapshot.language_distribution.get("rust").copied(), Some(1));
372 }
373
374 #[test]
375 fn awareness_snapshot_projects_plugin_activation_inventory_with_slot_conflicts() {
376 let root = unique_tmp_dir("loong-awareness-slots");
377 fs::create_dir_all(&root).expect("create temp root");
378
379 fs::write(
380 root.join("first.py"),
381 r#"
382# LOONG_PLUGIN_START
383# {
384# "plugin_id": "search-a",
385# "provider_id": "search-a",
386# "connector_name": "search-a",
387# "channel_id": "primary",
388# "endpoint": "https://example.com/a",
389# "capabilities": ["InvokeConnector"],
390# "slot_claims": [{"slot":"provider:web_search","key":"default","mode":"exclusive"}],
391# "metadata": {"bridge_kind":"http_json"}
392# }
393# LOONG_PLUGIN_END
394"#,
395 )
396 .expect("write first plugin");
397 fs::write(
398 root.join("second.py"),
399 r#"
400# LOONG_PLUGIN_START
401# {
402# "plugin_id": "search-b",
403# "provider_id": "search-b",
404# "connector_name": "search-b",
405# "channel_id": "primary",
406# "endpoint": "https://example.com/b",
407# "capabilities": ["InvokeConnector"],
408# "slot_claims": [{"slot":"provider:web_search","key":"default","mode":"exclusive"}],
409# "metadata": {"bridge_kind":"http_json"}
410# }
411# LOONG_PLUGIN_END
412"#,
413 )
414 .expect("write second plugin");
415
416 let engine = CodebaseAwarenessEngine::new();
417 let snapshot = engine
418 .snapshot(&CodebaseAwarenessConfig {
419 roots: vec![root.display().to_string()],
420 plugin_roots: vec![root.display().to_string()],
421 proposed_mutations: Vec::new(),
422 architecture_policy: ArchitectureBoundaryPolicy::default(),
423 })
424 .expect("awareness snapshot should succeed");
425
426 assert_eq!(snapshot.plugin_activation_reports.len(), 1);
427 assert_eq!(snapshot.plugin_activation_reports[0].blocked_plugins, 2);
428 assert_eq!(snapshot.plugin_activation_inventory.len(), 2);
429 assert!(snapshot.plugin_activation_inventory.iter().all(|entry| {
430 entry
431 .activation_status
432 .is_some_and(|status| status.as_str() == "blocked_slot_claim_conflict")
433 }));
434 }
435}