devalang_core/config/
stats.rs

1use super::settings::get_devalang_homedir;
2use crate::config::driver::ProjectConfig;
3use crate::core::{
4    parser::statement::{Statement, StatementKind},
5    store::global::GlobalStore,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::{
10    fs,
11    io::Write,
12    path::Path,
13    path::PathBuf,
14    sync::{Mutex, OnceLock},
15};
16
17#[derive(Debug, Deserialize, Clone, Serialize)]
18pub struct StatsCounts {
19    pub nb_files: usize,
20    pub nb_modules: usize,
21    pub nb_lines: usize,
22    pub nb_banks: usize,
23    pub nb_plugins: usize,
24}
25
26#[derive(Debug, Deserialize, Clone, Serialize)]
27pub struct StatsFeatures {
28    pub uses_imports: bool,
29    pub uses_functions: bool,
30    pub uses_groups: bool,
31    pub uses_automations: bool,
32    pub uses_loops: bool,
33}
34
35#[derive(Debug, Deserialize, Clone, Serialize)]
36pub struct StatsAudio {
37    pub avg_bpm: Option<u32>,
38    pub has_synths: bool,
39    pub has_samples: bool,
40}
41
42#[derive(Debug, Deserialize, Clone, Serialize)]
43pub struct ProjectStats {
44    pub counts: StatsCounts,
45    pub features: StatsFeatures,
46    pub audio: StatsAudio,
47}
48
49impl ProjectStats {
50    pub fn new() -> Self {
51        ProjectStats {
52            counts: StatsCounts {
53                nb_files: 0,
54                nb_modules: 0,
55                nb_lines: 0,
56                nb_banks: 0,
57                nb_plugins: 0,
58            },
59            features: StatsFeatures {
60                uses_imports: false,
61                uses_functions: false,
62                uses_groups: false,
63                uses_automations: false,
64                uses_loops: false,
65            },
66            audio: StatsAudio {
67                avg_bpm: None,
68                has_synths: false,
69                has_samples: false,
70            },
71        }
72    }
73
74    // Returns the in-memory stats if available, otherwise loads from file,
75    // otherwise returns a default struct.
76    pub fn get() -> Result<Self, String> {
77        if let Some(stats) = get_memory_stats() {
78            return Ok(stats);
79        }
80        if let Some(stats) = load_from_file() {
81            return Ok(stats);
82        }
83        Ok(Self::new())
84    }
85
86    // Saves stats to stats.json under the devalang home directory.
87    pub fn persist(&self) -> Result<(), String> {
88        save_to_file(self)
89    }
90}
91
92// ----------------------------
93// Storage helpers (memory/file)
94// ----------------------------
95
96static STATS_MEMORY: OnceLock<Mutex<ProjectStats>> = OnceLock::new();
97
98fn stats_file_path() -> PathBuf {
99    get_devalang_homedir().join("stats.json")
100}
101
102pub fn set_memory_stats(stats: ProjectStats) {
103    let m = STATS_MEMORY.get_or_init(|| Mutex::new(ProjectStats::new()));
104    if let Ok(mut guard) = m.lock() {
105        *guard = stats;
106    }
107}
108
109pub fn get_memory_stats() -> Option<ProjectStats> {
110    let m = STATS_MEMORY.get_or_init(|| Mutex::new(ProjectStats::new()));
111    if let Ok(guard) = m.lock() {
112        // If it's the default value (all zero/false/None), still return it; caller can decide.
113        return Some(guard.clone());
114    }
115    None
116}
117
118pub fn load_from_file() -> Option<ProjectStats> {
119    let path = stats_file_path();
120    if !path.exists() {
121        return None;
122    }
123    match fs::read_to_string(&path) {
124        Ok(content) => match serde_json::from_str::<ProjectStats>(&content) {
125            Ok(stats) => Some(stats),
126            Err(_) => None,
127        },
128        Err(_) => None,
129    }
130}
131
132pub fn save_to_file(stats: &ProjectStats) -> Result<(), String> {
133    let path = stats_file_path();
134    let dir = path
135        .parent()
136        .unwrap_or(&get_devalang_homedir())
137        .to_path_buf();
138    if !dir.exists() {
139        fs::create_dir_all(&dir).map_err(|e| format!("failed to create stats dir: {}", e))?;
140    }
141    let json = serde_json::to_string_pretty(stats)
142        .map_err(|e| format!("failed to serialize stats: {}", e))?;
143    let mut file =
144        fs::File::create(&path).map_err(|e| format!("failed to create stats file: {}", e))?;
145    file.write_all(json.as_bytes())
146        .map_err(|e| format!("failed to write stats file: {}", e))?;
147    Ok(())
148}
149
150// Compute stats from modules and store
151pub fn compute_from(
152    statements_by_module: &HashMap<String, Vec<Statement>>,
153    global_store: &GlobalStore,
154    config: &Option<ProjectConfig>,
155    _output_dir: Option<&str>,
156) -> ProjectStats {
157    let mut stats = ProjectStats::new();
158
159    // Counts
160    stats.counts.nb_modules = statements_by_module.len();
161    stats.counts.nb_files = stats.counts.nb_modules; // number of source files loaded
162
163    let mut total_lines = 0usize;
164    for (path, _stmts) in statements_by_module.iter() {
165        let p = Path::new(path);
166        if let Ok(content) = fs::read_to_string(p) {
167            total_lines += content.lines().count();
168        }
169    }
170    stats.counts.nb_lines = total_lines;
171
172    // Banks/Plugins from config
173    if let Some(cfg) = config {
174        stats.counts.nb_banks = cfg.banks.as_ref().map(|v| v.len()).unwrap_or(0);
175        stats.counts.nb_plugins = cfg.plugins.as_ref().map(|v| v.len()).unwrap_or(0);
176    }
177
178    // Features and audio
179    let mut bpm_sum: f32 = 0.0;
180    let mut bpm_count: usize = 0;
181
182    fn visit(
183        stmts: &[Statement],
184        acc: &mut ProjectStats,
185        bpm_sum: &mut f32,
186        bpm_count: &mut usize,
187    ) {
188        for s in stmts {
189            match &s.kind {
190                StatementKind::Import { .. } => acc.features.uses_imports = true,
191                StatementKind::Function { body, .. } => {
192                    acc.features.uses_functions = true;
193                    visit(body, acc, bpm_sum, bpm_count);
194                }
195                StatementKind::Group => {
196                    acc.features.uses_groups = true;
197                    if let Some(body) = extract_body_block(&s.value) {
198                        visit(body, acc, bpm_sum, bpm_count);
199                    }
200                }
201                StatementKind::Automate { .. } => acc.features.uses_automations = true,
202                StatementKind::Loop => {
203                    acc.features.uses_loops = true;
204                    if let Some(body) = extract_body_block(&s.value) {
205                        visit(body, acc, bpm_sum, bpm_count);
206                    }
207                }
208                StatementKind::Synth => acc.audio.has_synths = true,
209                StatementKind::Tempo => {
210                    if let crate::core::shared::value::Value::Number(bpm) = &s.value {
211                        *bpm_sum += *bpm as f32;
212                        *bpm_count += 1;
213                    }
214                }
215                StatementKind::Trigger { entity, .. } => {
216                    let e = entity.to_lowercase();
217                    if e.ends_with(".wav") || e.ends_with(".mp3") || e.contains("devalang://bank/")
218                    {
219                        acc.audio.has_samples = true;
220                    }
221                }
222                _ => {}
223            }
224        }
225    }
226
227    fn extract_body_block(v: &crate::core::shared::value::Value) -> Option<&[Statement]> {
228        use crate::core::shared::value::Value;
229        if let Value::Map(map) = v {
230            if let Some(Value::Block(stmts)) = map.get("body") {
231                return Some(stmts.as_slice());
232            }
233        }
234        None
235    }
236
237    for (_path, stmts) in statements_by_module.iter() {
238        visit(stmts, &mut stats, &mut bpm_sum, &mut bpm_count);
239    }
240
241    if !stats.audio.has_samples {
242        for (_k, v) in global_store.variables.variables.iter() {
243            if let crate::core::shared::value::Value::String(s) = v {
244                if s.starts_with("devalang://bank/") {
245                    stats.audio.has_samples = true;
246                    break;
247                }
248            }
249        }
250    }
251
252    if bpm_count > 0 {
253        stats.audio.avg_bpm = Some((bpm_sum / bpm_count as f32).round() as u32);
254    }
255
256    stats
257}