1pub mod analyzer;
10pub mod analyzers;
11pub mod mesh;
12
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use async_trait::async_trait;
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use tracing::debug;
21
22use crate::health::HealthStatus;
23use crate::service::{ServiceType, SystemService};
24
25pub use analyzer::{AnalysisContext, Analyzer, AnalyzerRegistry, AssessmentDiff};
26
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct AssessmentConfig {
32 #[serde(default)]
34 pub project: Option<ProjectConfig>,
35 #[serde(default)]
37 pub assessment: Option<AssessmentSettings>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ProjectConfig {
43 pub name: String,
45 #[serde(default)]
47 pub org: Option<String>,
48 #[serde(default)]
50 pub environment: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct AssessmentSettings {
56 #[serde(default)]
58 pub version: Option<u32>,
59 #[serde(default)]
61 pub sources: Option<SourcesConfig>,
62 #[serde(default)]
64 pub triggers: Option<TriggersConfig>,
65 #[serde(default)]
67 pub reporting: Option<ReportingConfig>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
72pub struct SourcesConfig {
73 #[serde(default)]
74 pub files: Option<FilePatterns>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79pub struct FilePatterns {
80 #[serde(default)]
81 pub patterns: Vec<String>,
82 #[serde(default)]
83 pub exclude: Vec<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct TriggersConfig {
89 #[serde(default)]
91 pub filesystem: Option<FilesystemTrigger>,
92 #[serde(default)]
94 pub scheduled: Option<ScheduledTrigger>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct FilesystemTrigger {
100 pub enabled: bool,
101 #[serde(default = "default_debounce_ms")]
102 pub debounce_ms: u64,
103 #[serde(default)]
104 pub patterns: Vec<String>,
105 #[serde(default)]
106 pub exclude: Vec<String>,
107}
108
109fn default_debounce_ms() -> u64 {
110 2000
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct ScheduledTrigger {
116 pub enabled: bool,
117 #[serde(default = "default_cron")]
118 pub cron: String,
119 #[serde(default = "default_scope")]
120 pub scope: String,
121}
122
123fn default_cron() -> String {
124 "0 2 * * *".to_string()
125}
126
127fn default_scope() -> String {
128 "full".to_string()
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
133pub struct ReportingConfig {
134 #[serde(default)]
135 pub default_format: Option<String>,
136 #[serde(default)]
137 pub save_artifacts: Option<bool>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct AssessmentReport {
145 pub timestamp: DateTime<Utc>,
147 pub scope: String,
149 pub project: String,
151 pub files_scanned: usize,
153 pub summary: AssessmentSummary,
155 pub findings: Vec<Finding>,
157 pub analyzers_run: Vec<String>,
159}
160
161#[derive(Debug, Clone, Default, Serialize, Deserialize)]
163pub struct AssessmentSummary {
164 pub total_files: usize,
165 pub lines_of_code: usize,
166 pub rust_files: usize,
167 pub typescript_files: usize,
168 pub config_files: usize,
169 pub doc_files: usize,
170 pub dependency_files: usize,
171 pub complexity_warnings: usize,
172 pub coherence_score: f64,
173 pub symbols_extracted: usize,
174 pub avg_complexity: f64,
175 #[serde(default)]
177 pub discovery_rounds: usize,
178 #[serde(default)]
180 pub files_discovered: usize,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct Finding {
186 pub severity: String,
187 pub category: String,
188 pub file: String,
189 pub line: Option<usize>,
190 pub message: String,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct PeerInfo {
196 pub name: String,
197 pub location: String,
198 pub linked_at: DateTime<Utc>,
199 pub last_assessment: Option<AssessmentReport>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct ComparisonReport {
205 pub local: AssessmentReport,
206 pub remote_name: String,
207 pub remote: AssessmentReport,
208 pub shared_deps: Vec<String>,
209}
210
211pub struct AssessmentService {
220 started: AtomicBool,
221 latest: Mutex<Option<AssessmentReport>>,
222 peers: Mutex<Vec<PeerInfo>>,
223 previous_report_path: Mutex<Option<PathBuf>>,
225 mesh_coordinator: Option<Arc<mesh::MeshCoordinator>>,
229}
230
231impl AssessmentService {
232 pub fn new() -> Self {
233 Self {
234 started: AtomicBool::new(false),
235 latest: Mutex::new(None),
236 peers: Mutex::new(Vec::new()),
237 previous_report_path: Mutex::new(None),
238 mesh_coordinator: None,
239 }
240 }
241
242 pub fn with_mesh(node_id: String, project_name: String) -> Self {
244 Self {
245 started: AtomicBool::new(false),
246 latest: Mutex::new(None),
247 peers: Mutex::new(Vec::new()),
248 previous_report_path: Mutex::new(None),
249 mesh_coordinator: Some(Arc::new(mesh::MeshCoordinator::new(
250 node_id,
251 project_name,
252 ))),
253 }
254 }
255
256 pub fn with_mesh_coordinator(coordinator: Arc<mesh::MeshCoordinator>) -> Self {
261 Self {
262 started: AtomicBool::new(false),
263 latest: Mutex::new(None),
264 peers: Mutex::new(Vec::new()),
265 previous_report_path: Mutex::new(None),
266 mesh_coordinator: Some(coordinator),
267 }
268 }
269
270 pub fn mesh_coordinator(&self) -> Option<&mesh::MeshCoordinator> {
272 self.mesh_coordinator.as_deref()
273 }
274
275 pub fn mesh_coordinator_arc(&self) -> Option<Arc<mesh::MeshCoordinator>> {
277 self.mesh_coordinator.clone()
278 }
279
280 pub fn queue_mesh_propagation(&self, report: &AssessmentReport) {
285 let Some(ref mc) = self.mesh_coordinator else {
286 return;
287 };
288 if let Some(previous) = self.load_previous_report() {
289 let diff = analyzer::diff_reports(report, &previous);
290 if !diff.findings_new.is_empty()
292 || !diff.findings_resolved.is_empty()
293 || diff.complexity_delta != 0
294 || (diff.coherence_delta.abs() > f64::EPSILON)
295 {
296 let msg = mc.build_finding_diff(report, &diff);
297 mc.set_pending_broadcast(msg);
298 return;
299 }
300 }
301 let gossip = mc.build_gossip(report);
302 mc.set_pending_broadcast(gossip);
303 }
304
305 pub fn set_previous_report_path(&self, path: PathBuf) {
311 *self.previous_report_path.lock().unwrap() = Some(path);
312 }
313
314 fn load_previous_report(&self) -> Option<AssessmentReport> {
316 let guard = self.previous_report_path.lock().unwrap();
317 let path = guard.as_ref()?;
318 let content = std::fs::read_to_string(path).ok()?;
319 serde_json::from_str(&content).ok()
320 }
321
322 pub fn diff_latest(&self) -> Option<AssessmentDiff> {
324 let current = self.get_latest()?;
325 let previous = self.load_previous_report()?;
326 Some(analyzer::diff_reports(¤t, &previous))
327 }
328
329 pub fn run_assessment(
339 &self,
340 project_dir: &Path,
341 scope: &str,
342 _format: &str,
343 ) -> Result<AssessmentReport, String> {
344 self.run_assessment_with_registry(
345 project_dir,
346 scope,
347 _format,
348 AnalyzerRegistry::with_defaults(),
349 )
350 }
351
352 pub fn run_assessment_with_registry(
354 &self,
355 project_dir: &Path,
356 scope: &str,
357 _format: &str,
358 registry: AnalyzerRegistry,
359 ) -> Result<AssessmentReport, String> {
360 let files = match scope {
361 "commit" => collect_git_changed_files(project_dir)?,
362 "ci" => collect_files_filtered(project_dir, is_ci_file),
363 "dependency" => collect_files_filtered(project_dir, is_dependency_file),
364 _ => collect_all_files(project_dir),
365 };
366
367 let mut summary = AssessmentSummary::default();
368 #[allow(unused_mut)]
369 let mut total_complexity_sum: f64 = 0.0;
370 #[allow(unused_mut)]
371 let mut complexity_count: usize = 0;
372
373 let mut ts_findings = Vec::new();
375 for path in &files {
376 let rel = path.strip_prefix(project_dir).unwrap_or(path);
377 let _rel_str = rel.display().to_string();
378
379 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
380 match ext {
381 "rs" => summary.rust_files += 1,
382 "ts" | "tsx" => summary.typescript_files += 1,
383 "toml" | "yaml" | "yml" | "json" if is_config_file(path) => {
384 summary.config_files += 1;
385 }
386 "md" | "txt" | "adoc" => summary.doc_files += 1,
387 _ => {}
388 }
389 if is_dependency_file(path) {
390 summary.dependency_files += 1;
391 }
392
393 let content = match std::fs::read_to_string(path) {
394 Ok(c) => c,
395 Err(_) => continue,
396 };
397
398 let line_count = content.lines().count();
399 summary.lines_of_code += line_count;
400
401 #[cfg(feature = "treesitter")]
403 if ext == "rs" {
404 if let Ok(tree) = clawft_plugin_treesitter::analysis::parse_source(
405 &content,
406 clawft_plugin_treesitter::types::Language::Rust,
407 ) {
408 let symbols = clawft_plugin_treesitter::analysis::extract_symbols(
409 &tree,
410 &content,
411 clawft_plugin_treesitter::types::Language::Rust,
412 );
413 summary.symbols_extracted += symbols.len();
414
415 let metrics = clawft_plugin_treesitter::analysis::calculate_complexity(
416 &tree,
417 &content,
418 clawft_plugin_treesitter::types::Language::Rust,
419 );
420 for func in &metrics.functions {
421 total_complexity_sum += func.complexity as f64;
422 complexity_count += 1;
423 if func.complexity > 10 {
424 summary.complexity_warnings += 1;
425 ts_findings.push(Finding {
426 severity: "warning".into(),
427 category: "complexity".into(),
428 file: _rel_str.clone(),
429 line: Some(func.start_line),
430 message: format!(
431 "Function '{}' has cyclomatic complexity {}",
432 func.name, func.complexity
433 ),
434 });
435 }
436 }
437 }
438 }
439 }
440
441 let previous_report = self.load_previous_report();
443 let context = AnalysisContext {
444 scope: scope.to_string(),
445 previous_report,
446 };
447 let analyzer_ids = registry.analyzer_ids();
448 let mut findings = registry.run_all(project_dir, &files, &context);
449
450 findings.append(&mut ts_findings);
452
453 let warning_count = findings.iter().filter(|f| f.severity == "warning").count();
455 summary.complexity_warnings += findings
457 .iter()
458 .filter(|f| f.category == "size" && f.severity == "warning")
459 .count();
460
461 summary.total_files = files.len();
462 summary.avg_complexity = if complexity_count > 0 {
463 total_complexity_sum / complexity_count as f64
464 } else {
465 0.0
466 };
467 summary.coherence_score = if summary.total_files > 0 {
469 1.0 - (warning_count as f64 / summary.total_files as f64).min(1.0)
470 } else {
471 1.0
472 };
473
474 let mut report = AssessmentReport {
475 timestamp: Utc::now(),
476 scope: scope.to_string(),
477 project: project_dir.display().to_string(),
478 files_scanned: files.len(),
479 summary,
480 findings,
481 analyzers_run: analyzer_ids,
482 };
483
484 if let Some(mut insights) = self.generate_insights(&report) {
486 report.findings.append(&mut insights);
487 }
488
489 *self.latest.lock().unwrap() = Some(report.clone());
490
491 self.queue_mesh_propagation(&report);
494
495 debug!(
496 scope = scope,
497 files = report.files_scanned,
498 "assessment complete"
499 );
500 Ok(report)
501 }
502
503 pub fn load_config(&self, project: &Path) -> Option<AssessmentConfig> {
507 let config_path = project.join(".weftos/weave.toml");
508 let content = std::fs::read_to_string(&config_path).ok()?;
509 match toml::from_str::<AssessmentConfig>(&content) {
510 Ok(cfg) => Some(cfg),
511 Err(e) => {
512 tracing::warn!(
513 path = %config_path.display(),
514 error = %e,
515 "failed to parse assessment config"
516 );
517 None
518 }
519 }
520 }
521
522 pub fn get_latest(&self) -> Option<AssessmentReport> {
524 self.latest.lock().unwrap().clone()
525 }
526
527 pub fn list_peers(&self) -> Vec<PeerInfo> {
529 self.peers.lock().unwrap().clone()
530 }
531
532 pub fn link_peer(&self, name: String, location: String) -> Result<(), String> {
534 let mut peers = self.peers.lock().unwrap();
535 if peers.iter().any(|p| p.name == name) {
536 return Err(format!("peer '{name}' already linked"));
537 }
538 peers.push(PeerInfo {
539 name,
540 location,
541 linked_at: Utc::now(),
542 last_assessment: None,
543 });
544 Ok(())
545 }
546
547 pub fn compare_with_peer(&self, peer_name: &str) -> Result<ComparisonReport, String> {
549 let local = self
550 .get_latest()
551 .ok_or_else(|| "no local assessment available; run an assessment first".to_string())?;
552
553 let peers = self.peers.lock().unwrap();
554 let peer = peers
555 .iter()
556 .find(|p| p.name == peer_name)
557 .ok_or_else(|| format!("peer '{peer_name}' not found"))?
558 .clone();
559 drop(peers);
560
561 let peer_dir = PathBuf::from(&peer.location);
562 if !peer_dir.exists() {
563 return Err(format!("peer directory '{}' does not exist", peer.location));
564 }
565
566 let remote = self.run_assessment(&peer_dir, &local.scope, "json")?;
567
568 {
570 let mut peers = self.peers.lock().unwrap();
571 if let Some(p) = peers.iter_mut().find(|p| p.name == peer_name) {
572 p.last_assessment = Some(remote.clone());
573 }
574 }
575
576 let local_deps: std::collections::HashSet<String> = local
578 .findings
579 .iter()
580 .filter(|f| f.category == "dependency")
581 .map(|f| f.file.clone())
582 .collect();
583 let remote_deps: std::collections::HashSet<String> = remote
584 .findings
585 .iter()
586 .filter(|f| f.category == "dependency")
587 .map(|f| f.file.clone())
588 .collect();
589 let shared_deps: Vec<String> = local_deps.intersection(&remote_deps).cloned().collect();
590
591 Ok(ComparisonReport {
592 local,
593 remote_name: peer_name.to_string(),
594 remote,
595 shared_deps,
596 })
597 }
598
599 pub fn run_progressive(
609 &self,
610 project: &Path,
611 scope: &str,
612 max_rounds: usize,
613 ) -> AssessmentReport {
614 let registry = AnalyzerRegistry::with_defaults();
615 let analyzer_ids = registry.analyzer_ids();
616
617 let initial_files = match scope {
618 "commit" => collect_git_changed_files(project).unwrap_or_default(),
619 "ci" => collect_files_filtered(project, is_ci_file),
620 "dependency" => collect_files_filtered(project, is_dependency_file),
621 _ => collect_all_files(project),
622 };
623
624 let previous_report = self.load_previous_report();
625 let context = AnalysisContext {
626 scope: scope.to_string(),
627 previous_report,
628 };
629
630 let mut all_scanned: std::collections::HashSet<PathBuf> =
631 initial_files.iter().cloned().collect();
632 let mut all_findings: Vec<Finding> = Vec::new();
633 let mut round = 0;
634 let mut total_discovered: usize = 0;
635 let mut files_to_scan = initial_files;
636
637 while round < max_rounds && !files_to_scan.is_empty() {
638 let findings = registry.run_all(project, &files_to_scan, &context);
639 all_findings.extend(findings.clone());
640 round += 1;
641
642 if round >= max_rounds {
643 break;
644 }
645
646 let mut new_files = Vec::new();
648 for finding in &findings {
649 if !matches!(
650 finding.category.as_str(),
651 "topology" | "network" | "data_source"
652 ) {
653 continue;
654 }
655 let hints = extract_path_hints(&finding.message, &finding.file, project);
657 for hint in hints {
658 if hint.exists() && hint.is_file() && !all_scanned.contains(&hint) {
659 all_scanned.insert(hint.clone());
660 new_files.push(hint);
661 }
662 }
663 }
664
665 for finding in &findings {
667 let path = project.join(&finding.file);
668 if let Some(parent) = path.parent()
669 && parent.is_dir()
670 {
671 let mut extra = Vec::new();
672 walk_dir(parent, &mut extra, &|_| true);
673 for f in extra {
674 if !all_scanned.contains(&f) {
675 all_scanned.insert(f.clone());
676 new_files.push(f);
677 }
678 }
679 }
680 }
681
682 total_discovered += new_files.len();
683 files_to_scan = new_files;
684 }
685
686 let all_files: Vec<PathBuf> = all_scanned.into_iter().collect();
688 let mut summary = AssessmentSummary {
689 total_files: all_files.len(),
690 discovery_rounds: round,
691 files_discovered: total_discovered,
692 ..Default::default()
693 };
694
695 for path in &all_files {
696 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
697 match ext {
698 "rs" => summary.rust_files += 1,
699 "ts" | "tsx" => summary.typescript_files += 1,
700 "toml" | "yaml" | "yml" | "json" if is_config_file(path) => {
701 summary.config_files += 1;
702 }
703 "md" | "txt" | "adoc" => summary.doc_files += 1,
704 _ => {}
705 }
706 if is_dependency_file(path) {
707 summary.dependency_files += 1;
708 }
709 if let Ok(content) = std::fs::read_to_string(path) {
710 summary.lines_of_code += content.lines().count();
711 }
712 }
713
714 let warning_count = all_findings
715 .iter()
716 .filter(|f| f.severity == "warning")
717 .count();
718 summary.complexity_warnings = all_findings
719 .iter()
720 .filter(|f| f.category == "size" && f.severity == "warning")
721 .count();
722 summary.coherence_score = if summary.total_files > 0 {
723 1.0 - (warning_count as f64 / summary.total_files as f64).min(1.0)
724 } else {
725 1.0
726 };
727
728 let mut report = AssessmentReport {
729 timestamp: Utc::now(),
730 scope: scope.to_string(),
731 project: project.display().to_string(),
732 files_scanned: all_files.len(),
733 summary,
734 findings: all_findings,
735 analyzers_run: analyzer_ids,
736 };
737
738 if let Some(mut insights) = self.generate_insights(&report) {
740 report.findings.append(&mut insights);
741 }
742
743 *self.latest.lock().unwrap() = Some(report.clone());
744
745 self.queue_mesh_propagation(&report);
747
748 debug!(
749 scope = scope,
750 rounds = round,
751 discovered = total_discovered,
752 files = report.files_scanned,
753 "progressive assessment complete"
754 );
755 report
756 }
757
758 pub fn generate_insights(&self, report: &AssessmentReport) -> Option<Vec<Finding>> {
769 let mut insights = Vec::new();
770 let summary = &report.summary;
771
772 if summary.coherence_score < 0.2 {
774 insights.push(Finding {
775 severity: "warning".into(),
776 category: "insight".into(),
777 file: String::new(),
778 line: None,
779 message: format!(
780 "Low coherence score ({:.0}%): consider adding documentation and reducing warnings to improve project health",
781 summary.coherence_score * 100.0
782 ),
783 });
784 }
785
786 if summary.total_files > 0
788 && summary.complexity_warnings as f64 / summary.total_files as f64 > 0.1
789 {
790 insights.push(Finding {
791 severity: "warning".into(),
792 category: "insight".into(),
793 file: String::new(),
794 line: None,
795 message: format!(
796 "High complexity ratio: {} of {} files have complexity warnings ({:.0}%) \
797 — consider refactoring large or deeply-nested modules",
798 summary.complexity_warnings,
799 summary.total_files,
800 (summary.complexity_warnings as f64 / summary.total_files as f64) * 100.0
801 ),
802 });
803 }
804
805 let security_count = report
807 .findings
808 .iter()
809 .filter(|f| f.category == "security")
810 .count();
811 if security_count > 0 {
812 let error_count = report
813 .findings
814 .iter()
815 .filter(|f| f.category == "security" && f.severity == "error")
816 .count();
817 insights.push(Finding {
818 severity: if error_count > 0 { "error" } else { "warning" }.into(),
819 category: "insight".into(),
820 file: String::new(),
821 line: None,
822 message: format!(
823 "Security review needed: {security_count} security finding(s) detected \
824 ({error_count} critical) — prioritize remediation before deployment"
825 ),
826 });
827 }
828
829 let service_findings = report
831 .findings
832 .iter()
833 .filter(|f| f.category == "topology" && f.message.starts_with("Service:"))
834 .count();
835 if service_findings > 5 {
836 insights.push(Finding {
837 severity: "info".into(),
838 category: "insight".into(),
839 file: String::new(),
840 line: None,
841 message: format!(
842 "Complex topology: {service_findings} services detected \
843 — consider an architecture review to verify service boundaries \
844 and communication patterns"
845 ),
846 });
847 }
848
849 let dep_files: std::collections::HashSet<&str> = report
851 .findings
852 .iter()
853 .filter(|f| f.category == "dependency")
854 .map(|f| f.file.as_str())
855 .collect();
856 let security_files: std::collections::HashSet<&str> = report
857 .findings
858 .iter()
859 .filter(|f| f.category == "security")
860 .map(|f| f.file.as_str())
861 .collect();
862 let overlap: Vec<&&str> = dep_files.intersection(&security_files).collect();
863 if !overlap.is_empty() {
864 let file_list: Vec<String> = overlap.iter().map(|f| f.to_string()).collect();
865 insights.push(Finding {
866 severity: "warning".into(),
867 category: "insight".into(),
868 file: String::new(),
869 line: None,
870 message: format!(
871 "Dependency files with security findings: {} \
872 — audit these manifests for vulnerable or compromised packages",
873 file_list.join(", ")
874 ),
875 });
876 }
877
878 Some(insights)
879 }
880}
881
882fn extract_path_hints(message: &str, _finding_file: &str, project: &Path) -> Vec<PathBuf> {
887 let mut paths = Vec::new();
888
889 for delim in ['"', '\''] {
891 let mut rest = message;
892 while let Some(start) = rest.find(delim) {
893 rest = &rest[start + 1..];
894 if let Some(end) = rest.find(delim) {
895 let candidate = &rest[..end];
896 if (candidate.contains('/') || candidate.contains('.'))
898 && !candidate.contains(' ')
899 && !candidate.starts_with("http")
900 {
901 let full = project.join(candidate);
902 paths.push(full);
903 }
904 rest = &rest[end + 1..];
905 } else {
906 break;
907 }
908 }
909 }
910
911 paths
912}
913
914impl Default for AssessmentService {
915 fn default() -> Self {
916 Self::new()
917 }
918}
919
920#[async_trait]
921impl SystemService for AssessmentService {
922 fn name(&self) -> &str {
923 "assessment"
924 }
925
926 fn service_type(&self) -> ServiceType {
927 ServiceType::Custom("assessment".into())
928 }
929
930 async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
931 self.started.store(true, Ordering::Relaxed);
932 tracing::info!("assessment service started");
933 Ok(())
934 }
935
936 async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
937 self.started.store(false, Ordering::Relaxed);
938 tracing::info!("assessment service stopped");
939 Ok(())
940 }
941
942 async fn health_check(&self) -> HealthStatus {
943 if self.started.load(Ordering::Relaxed) {
944 HealthStatus::Healthy
945 } else {
946 HealthStatus::Degraded("not started".into())
947 }
948 }
949}
950
951fn collect_all_files(dir: &Path) -> Vec<PathBuf> {
954 let mut files = Vec::new();
955 walk_dir(dir, &mut files, &|_| true);
956 files
957}
958
959fn collect_files_filtered<F: Fn(&Path) -> bool>(dir: &Path, predicate: F) -> Vec<PathBuf> {
960 let mut files = Vec::new();
961 walk_dir(dir, &mut files, &predicate);
962 files
963}
964
965fn walk_dir<F: Fn(&Path) -> bool>(dir: &Path, out: &mut Vec<PathBuf>, predicate: &F) {
966 let entries = match std::fs::read_dir(dir) {
967 Ok(e) => e,
968 Err(_) => return,
969 };
970 for entry in entries.flatten() {
971 let path = entry.path();
972 if let Some(name) = path.file_name().and_then(|n| n.to_str())
974 && (name.starts_with('.') || name == "target" || name == "node_modules")
975 {
976 continue;
977 }
978 if path.is_dir() {
979 walk_dir(&path, out, predicate);
980 } else if predicate(&path) {
981 out.push(path);
982 }
983 }
984}
985
986fn collect_git_changed_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
987 let output = std::process::Command::new("git")
988 .args(["diff", "--name-only", "HEAD~1"])
989 .current_dir(dir)
990 .output()
991 .map_err(|e| format!("failed to run git diff: {e}"))?;
992
993 if !output.status.success() {
994 return Err(format!(
995 "git diff failed: {}",
996 String::from_utf8_lossy(&output.stderr)
997 ));
998 }
999
1000 let files = String::from_utf8_lossy(&output.stdout)
1001 .lines()
1002 .map(|l| dir.join(l))
1003 .filter(|p| p.exists())
1004 .collect();
1005 Ok(files)
1006}
1007
1008fn is_ci_file(path: &Path) -> bool {
1009 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1010 let path_str = path.display().to_string();
1011 name.contains("ci")
1012 || name.contains("CI")
1013 || path_str.contains(".github/workflows")
1014 || name == "Jenkinsfile"
1015 || name == ".gitlab-ci.yml"
1016}
1017
1018fn is_dependency_file(path: &Path) -> bool {
1019 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1020 matches!(
1021 name,
1022 "Cargo.toml"
1023 | "Cargo.lock"
1024 | "package.json"
1025 | "package-lock.json"
1026 | "yarn.lock"
1027 | "pnpm-lock.yaml"
1028 | "go.mod"
1029 | "go.sum"
1030 | "requirements.txt"
1031 | "Pipfile"
1032 | "Pipfile.lock"
1033 )
1034}
1035
1036fn is_config_file(path: &Path) -> bool {
1037 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
1038 if is_dependency_file(path) {
1040 return false;
1041 }
1042 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
1043 matches!(ext, "toml" | "yaml" | "yml" | "json")
1044 || name == ".editorconfig"
1045 || name == ".rustfmt.toml"
1046 || name == "clippy.toml"
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use std::fs;
1053
1054 fn setup_test_dir() -> tempfile::TempDir {
1055 let dir = tempfile::tempdir().unwrap();
1056 let mut big = String::new();
1058 for i in 0..510 {
1059 big.push_str(&format!("// line {i}\n"));
1060 }
1061 fs::write(dir.path().join("big.rs"), &big).unwrap();
1062
1063 fs::write(
1065 dir.path().join("small.rs"),
1066 "fn main() {\n // TODO: implement\n}\n",
1067 )
1068 .unwrap();
1069
1070 fs::write(dir.path().join("config.toml"), "[section]\nkey = 1\n").unwrap();
1072
1073 fs::write(dir.path().join("README.md"), "# Readme\n").unwrap();
1075
1076 fs::write(
1078 dir.path().join("Cargo.toml"),
1079 "[package]\nname = \"test\"\n[dependencies]\nserde = \"1.0\"\n",
1080 )
1081 .unwrap();
1082
1083 dir
1084 }
1085
1086 #[test]
1087 fn full_assessment_scans_files() {
1088 let dir = setup_test_dir();
1089 let svc = AssessmentService::new();
1090 let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1091
1092 assert_eq!(report.scope, "full");
1093 assert!(report.files_scanned >= 4);
1094 assert!(report.summary.rust_files >= 2);
1095 assert!(report.summary.doc_files >= 1);
1096 assert!(report.summary.dependency_files >= 1);
1097 }
1098
1099 #[test]
1100 fn detects_large_files() {
1101 let dir = setup_test_dir();
1102 let svc = AssessmentService::new();
1103 let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1104
1105 let size_warnings: Vec<_> = report
1106 .findings
1107 .iter()
1108 .filter(|f| f.category == "size")
1109 .collect();
1110 assert!(!size_warnings.is_empty(), "should detect >500 line file");
1111 }
1112
1113 #[test]
1114 fn detects_todos() {
1115 let dir = setup_test_dir();
1116 let svc = AssessmentService::new();
1117 let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1118
1119 let todos: Vec<_> = report
1120 .findings
1121 .iter()
1122 .filter(|f| f.category == "todo")
1123 .collect();
1124 assert!(!todos.is_empty(), "should detect TODO comments");
1125 }
1126
1127 #[test]
1128 fn get_latest_returns_last() {
1129 let dir = setup_test_dir();
1130 let svc = AssessmentService::new();
1131 assert!(svc.get_latest().is_none());
1132
1133 svc.run_assessment(dir.path(), "full", "json").unwrap();
1134 assert!(svc.get_latest().is_some());
1135 }
1136
1137 #[test]
1138 fn load_config_parses_weave_toml() {
1139 let dir = tempfile::tempdir().unwrap();
1140 let weftos = dir.path().join(".weftos");
1141 fs::create_dir_all(&weftos).unwrap();
1142 fs::write(
1143 weftos.join("weave.toml"),
1144 r#"
1145[project]
1146name = "acme-app"
1147org = "acme"
1148environment = "development"
1149
1150[assessment]
1151version = 1
1152
1153[assessment.triggers.filesystem]
1154enabled = true
1155debounce_ms = 3000
1156patterns = ["**/*.rs"]
1157exclude = ["target/**"]
1158
1159[assessment.triggers.scheduled]
1160enabled = false
1161cron = "0 4 * * *"
1162scope = "full"
1163"#,
1164 )
1165 .unwrap();
1166
1167 let svc = AssessmentService::new();
1168 let cfg = svc.load_config(dir.path()).expect("should parse config");
1169
1170 let project = cfg.project.expect("project section");
1171 assert_eq!(project.name, "acme-app");
1172 assert_eq!(project.org.as_deref(), Some("acme"));
1173 assert_eq!(project.environment.as_deref(), Some("development"));
1174
1175 let triggers = cfg.assessment.unwrap().triggers.unwrap();
1176 let fs_trigger = triggers.filesystem.unwrap();
1177 assert!(fs_trigger.enabled);
1178 assert_eq!(fs_trigger.debounce_ms, 3000);
1179 assert_eq!(fs_trigger.patterns, vec!["**/*.rs"]);
1180
1181 let sched = triggers.scheduled.unwrap();
1182 assert!(!sched.enabled);
1183 assert_eq!(sched.cron, "0 4 * * *");
1184 }
1185
1186 #[test]
1187 fn load_config_returns_none_when_missing() {
1188 let dir = tempfile::tempdir().unwrap();
1189 let svc = AssessmentService::new();
1190 assert!(svc.load_config(dir.path()).is_none());
1191 }
1192
1193 #[test]
1194 fn peer_link_and_list() {
1195 let svc = AssessmentService::new();
1196 assert!(svc.list_peers().is_empty());
1197
1198 svc.link_peer("other".into(), "/tmp/other".into()).unwrap();
1199 assert_eq!(svc.list_peers().len(), 1);
1200 assert_eq!(svc.list_peers()[0].name, "other");
1201
1202 assert!(svc.link_peer("other".into(), "/tmp/other2".into()).is_err());
1204 }
1205
1206 #[test]
1207 fn dependency_scope_filters() {
1208 let dir = setup_test_dir();
1209 let svc = AssessmentService::new();
1210 let report = svc
1211 .run_assessment(dir.path(), "dependency", "json")
1212 .unwrap();
1213
1214 assert_eq!(report.files_scanned, 1); }
1217
1218 #[test]
1219 fn coherence_score_is_bounded() {
1220 let dir = setup_test_dir();
1221 let svc = AssessmentService::new();
1222 let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1223
1224 assert!(report.summary.coherence_score >= 0.0);
1225 assert!(report.summary.coherence_score <= 1.0);
1226 }
1227
1228 #[test]
1229 fn report_includes_analyzers_run() {
1230 let dir = setup_test_dir();
1231 let svc = AssessmentService::new();
1232 let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1233
1234 assert!(!report.analyzers_run.is_empty());
1235 assert!(report.analyzers_run.contains(&"complexity".to_string()));
1236 assert!(report.analyzers_run.contains(&"dependency".to_string()));
1237 assert!(report.analyzers_run.contains(&"security".to_string()));
1238 assert!(report.analyzers_run.contains(&"topology".to_string()));
1239 assert!(report.analyzers_run.contains(&"data_source".to_string()));
1240 }
1241
1242 #[test]
1243 fn dependency_analyzer_finds_deps() {
1244 let dir = setup_test_dir();
1245 let svc = AssessmentService::new();
1246 let report = svc.run_assessment(dir.path(), "full", "json").unwrap();
1247
1248 let dep_findings: Vec<_> = report
1249 .findings
1250 .iter()
1251 .filter(|f| f.category == "dependency")
1252 .collect();
1253 assert!(
1254 !dep_findings.is_empty(),
1255 "should detect dependencies in Cargo.toml"
1256 );
1257 }
1258
1259 #[test]
1260 fn diff_reports_computes_deltas() {
1261 let prev = AssessmentReport {
1262 timestamp: Utc::now(),
1263 scope: "full".into(),
1264 project: "/test".into(),
1265 files_scanned: 2,
1266 summary: AssessmentSummary {
1267 complexity_warnings: 1,
1268 coherence_score: 0.8,
1269 ..Default::default()
1270 },
1271 findings: vec![Finding {
1272 severity: "warning".into(),
1273 category: "size".into(),
1274 file: "old.rs".into(),
1275 line: None,
1276 message: "File has 600 lines (>500 limit)".into(),
1277 }],
1278 analyzers_run: vec!["complexity".into()],
1279 };
1280
1281 let curr = AssessmentReport {
1282 timestamp: Utc::now(),
1283 scope: "full".into(),
1284 project: "/test".into(),
1285 files_scanned: 3,
1286 summary: AssessmentSummary {
1287 complexity_warnings: 2,
1288 coherence_score: 0.7,
1289 ..Default::default()
1290 },
1291 findings: vec![Finding {
1292 severity: "warning".into(),
1293 category: "size".into(),
1294 file: "new.rs".into(),
1295 line: None,
1296 message: "File has 700 lines (>500 limit)".into(),
1297 }],
1298 analyzers_run: vec!["complexity".into()],
1299 };
1300
1301 let diff = analyzer::diff_reports(&curr, &prev);
1302 assert!(diff.files_added.contains(&"new.rs".to_string()));
1303 assert!(diff.files_removed.contains(&"old.rs".to_string()));
1304 assert_eq!(diff.findings_new.len(), 1);
1305 assert_eq!(diff.findings_resolved.len(), 1);
1306 assert_eq!(diff.complexity_delta, 1);
1307 assert!((diff.coherence_delta - (-0.1)).abs() < 0.001);
1308 }
1309
1310 #[test]
1311 fn custom_registry_runs_only_registered() {
1312 let dir = setup_test_dir();
1313 let svc = AssessmentService::new();
1314
1315 let registry = AnalyzerRegistry::new();
1317 let report = svc
1318 .run_assessment_with_registry(dir.path(), "full", "json", registry)
1319 .unwrap();
1320
1321 assert!(report.analyzers_run.is_empty());
1322 assert!(report.findings.is_empty());
1323 }
1324}