trident_client/
utils.rs

1use crate::error::Error;
2
3use crate::constants::*;
4use fehler::throws;
5use serde_json::Value;
6use std::path::Path;
7use std::path::PathBuf;
8use tokio::fs;
9
10#[macro_export]
11macro_rules! construct_path {
12    ($root:expr, $($component:expr),*) => {
13        {
14            let mut path = $root.to_owned();
15            $(path = path.join($component);)*
16            path
17        }
18    };
19}
20#[macro_export]
21macro_rules! load_template {
22    ($file:expr) => {
23        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), $file))
24    };
25}
26
27#[throws]
28pub async fn create_directory_all(path: &PathBuf) {
29    match path.exists() {
30        true => {}
31        false => {
32            fs::create_dir_all(path).await?;
33        }
34    };
35}
36
37#[throws]
38pub async fn create_file(root: &PathBuf, path: &PathBuf, content: &str) {
39    let file = path.strip_prefix(root)?.to_str().unwrap_or_default();
40
41    match path.exists() {
42        true => {
43            println!("{SKIP} [{file}] already exists")
44        }
45        false => {
46            fs::write(path, content).await?;
47            println!("{FINISH} [{file}] created");
48        }
49    };
50}
51
52#[throws]
53pub fn get_fuzz_id(fuzz_dir_path: &Path) -> i32 {
54    if fuzz_dir_path.exists() {
55        if fuzz_dir_path.read_dir()?.next().is_none() {
56            0
57        } else {
58            let entries = fuzz_dir_path.read_dir()?;
59            let mut max_num = -1;
60            for entry in entries {
61                let entry = entry?;
62                let file_name = entry.file_name().into_string().unwrap_or_default();
63                if file_name.starts_with("fuzz_") {
64                    let stripped = file_name.strip_prefix("fuzz_").unwrap_or_default();
65                    let num = stripped.parse::<i32>()?;
66                    max_num = max_num.max(num);
67                }
68            }
69            max_num + 1
70        }
71    } else {
72        0
73    }
74}
75
76/// Creates .fuzz-artifacts directory if it doesn't exist
77#[throws]
78pub async fn ensure_fuzz_artifacts_dir() -> PathBuf {
79    let artifacts_dir = PathBuf::from(".fuzz-artifacts");
80    create_directory_all(&artifacts_dir).await?;
81    artifacts_dir
82}
83
84/// Generates a unique filename in .fuzz-artifacts directory
85/// If the base filename already exists, appends a readable timestamp to make it unique
86#[throws]
87pub async fn generate_unique_fuzz_filename(
88    base_name: &str,
89    fuzz_test_name: &str,
90    extension: &str,
91) -> PathBuf {
92    let artifacts_dir = ensure_fuzz_artifacts_dir().await?;
93    let base_filename = format!("{}_{}.{}", base_name, fuzz_test_name, extension);
94    let mut target_path = artifacts_dir.join(&base_filename);
95
96    // If file already exists, append a readable timestamp to make it unique
97    if target_path.exists() {
98        use chrono::DateTime;
99        use chrono::Local;
100
101        // Try different timestamp formats until we find a unique one
102        let now: DateTime<Local> = Local::now();
103
104        // First try: YYYY-MM-DD_HH-MM-SS format
105        let timestamp = now.format("%Y-%m-%d_%H-%M-%S").to_string();
106        let unique_filename = format!(
107            "{}_{}-{}.{}",
108            base_name, fuzz_test_name, timestamp, extension
109        );
110        target_path = artifacts_dir.join(&unique_filename);
111
112        // If that still exists (very unlikely), add milliseconds
113        if target_path.exists() {
114            let timestamp_with_ms = now.format("%Y-%m-%d_%H-%M-%S-%3f").to_string();
115            let unique_filename = format!(
116                "{}_{}-{}.{}",
117                base_name, fuzz_test_name, timestamp_with_ms, extension
118            );
119            target_path = artifacts_dir.join(&unique_filename);
120        }
121    }
122
123    target_path
124}
125
126/// Merges JSON values: objects recursively, arrays by adding unique items, primitives by replacing
127fn merge_json(existing: &mut Value, new: &Value) {
128    match (existing, new) {
129        (Value::Object(existing_map), Value::Object(new_map)) => {
130            for (key, new_val) in new_map {
131                existing_map
132                    .entry(key.clone())
133                    .and_modify(|existing_val| merge_json(existing_val, new_val))
134                    .or_insert_with(|| new_val.clone());
135            }
136        }
137        (Value::Array(existing_arr), Value::Array(new_arr)) => {
138            // Add unique items only
139            for item in new_arr {
140                if !existing_arr.contains(item) {
141                    existing_arr.push(item.clone());
142                }
143            }
144        }
145        (existing_val, new_val) => {
146            *existing_val = new_val.clone();
147        }
148    }
149}
150
151/// Strips trailing commas from JSON (common in VSCode settings files)
152fn strip_trailing_commas(json_str: &str) -> String {
153    let chars: Vec<char> = json_str.chars().collect();
154    let mut result = String::with_capacity(json_str.len());
155
156    for i in 0..chars.len() {
157        if chars[i] == ',' {
158            // Look ahead: skip comma if only whitespace before } or ]
159            let remaining = &chars[i + 1..];
160            if remaining.iter().take_while(|c| c.is_whitespace()).count() == remaining.len()
161                || remaining
162                    .iter()
163                    .find(|c| !c.is_whitespace())
164                    .is_some_and(|c| *c == '}' || *c == ']')
165            {
166                continue;
167            }
168        }
169        result.push(chars[i]);
170    }
171    result
172}
173
174/// Creates or updates a JSON file with intelligent merging
175#[throws]
176pub async fn create_or_update_json_file(root: &PathBuf, path: &PathBuf, content: &str) {
177    let file = path.strip_prefix(root)?.to_str().unwrap_or_default();
178
179    if !path.exists() {
180        fs::write(path, content).await?;
181        println!("{FINISH} [{file}] created");
182        return;
183    }
184
185    let existing_content = fs::read_to_string(path).await?;
186
187    // Empty file - just write new content
188    if existing_content.trim().is_empty() {
189        fs::write(path, content).await?;
190        println!("{FINISH} [{file}] created (was empty)");
191        return;
192    }
193
194    // Try to parse, fixing trailing commas if needed
195    let cleaned = strip_trailing_commas(&existing_content);
196    let mut existing_json: Value = match serde_json::from_str(&cleaned) {
197        Ok(json) => json,
198        Err(e) => {
199            // Invalid JSON - backup and replace
200            eprintln!("Warning: Invalid JSON in {}: {}", file, e);
201            let backup_path = path.with_extension("json.backup");
202            fs::write(&backup_path, &existing_content).await?;
203            fs::write(path, content).await?;
204            println!("{UPDATED} [{file}] (backed up invalid JSON)");
205            return;
206        }
207    };
208
209    // Merge and write
210    let new_json: Value = serde_json::from_str(content)?;
211    merge_json(&mut existing_json, &new_json);
212    let merged = serde_json::to_string_pretty(&existing_json)?;
213    fs::write(path, merged).await?;
214    println!("{UPDATED} [{file}] merged with existing settings");
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use serde_json::json;
221
222    #[test]
223    fn test_merge_json_objects() {
224        let mut existing = json!({
225            "key1": "value1",
226            "key2": {
227                "nested": "old"
228            }
229        });
230
231        let new = json!({
232            "key2": {
233                "nested": "new",
234                "added": "value"
235            },
236            "key3": "value3"
237        });
238
239        merge_json(&mut existing, &new);
240
241        assert_eq!(existing["key1"], "value1");
242        assert_eq!(existing["key2"]["nested"], "new");
243        assert_eq!(existing["key2"]["added"], "value");
244        assert_eq!(existing["key3"], "value3");
245    }
246
247    #[test]
248    fn test_merge_json_arrays() {
249        let mut existing = json!({
250            "linkedProjects": ["./Cargo.toml"]
251        });
252
253        let new = json!({
254            "linkedProjects": ["./trident-tests/Cargo.toml"]
255        });
256
257        merge_json(&mut existing, &new);
258
259        let projects = existing["linkedProjects"].as_array().unwrap();
260        assert_eq!(projects.len(), 2);
261        assert!(projects.contains(&json!("./Cargo.toml")));
262        assert!(projects.contains(&json!("./trident-tests/Cargo.toml")));
263    }
264
265    #[test]
266    fn test_merge_json_arrays_no_duplicates() {
267        let mut existing = json!({
268            "linkedProjects": ["./Cargo.toml", "./trident-tests/Cargo.toml"]
269        });
270
271        let new = json!({
272            "linkedProjects": ["./Cargo.toml", "./trident-tests/Cargo.toml"]
273        });
274
275        merge_json(&mut existing, &new);
276
277        let projects = existing["linkedProjects"].as_array().unwrap();
278        assert_eq!(projects.len(), 2);
279    }
280
281    #[test]
282    fn test_merge_json_primitive_override() {
283        let mut existing = json!({
284            "setting": "old_value"
285        });
286
287        let new = json!({
288            "setting": "new_value"
289        });
290
291        merge_json(&mut existing, &new);
292
293        assert_eq!(existing["setting"], "new_value");
294    }
295
296    #[test]
297    fn test_merge_json_complex() {
298        let mut existing = json!({
299            "rust-analyzer.linkedProjects": ["./Cargo.toml"],
300            "editor.formatOnSave": true,
301            "custom": {
302                "nested": "value"
303            }
304        });
305
306        let new = json!({
307            "rust-analyzer.linkedProjects": ["./trident-tests/Cargo.toml"],
308            "editor.rulers": [80, 120]
309        });
310
311        merge_json(&mut existing, &new);
312
313        let projects = existing["rust-analyzer.linkedProjects"].as_array().unwrap();
314        assert_eq!(projects.len(), 2);
315        assert_eq!(existing["editor.formatOnSave"], true);
316        assert_eq!(existing["editor.rulers"], json!([80, 120]));
317        assert_eq!(existing["custom"]["nested"], "value");
318    }
319
320    #[test]
321    fn test_strip_trailing_commas_simple() {
322        let input = r#"{
323  "key": "value",
324}"#;
325        let expected = r#"{
326  "key": "value"
327}"#;
328        assert_eq!(strip_trailing_commas(input), expected);
329    }
330
331    #[test]
332    fn test_strip_trailing_commas_array() {
333        let input = r#"{
334  "items": [
335    "item1",
336    "item2",
337  ]
338}"#;
339        let expected = r#"{
340  "items": [
341    "item1",
342    "item2"
343  ]
344}"#;
345        assert_eq!(strip_trailing_commas(input), expected);
346    }
347
348    #[test]
349    fn test_strip_trailing_commas_nested() {
350        let input = r#"{
351  "outer": {
352    "inner": "value",
353  },
354  "array": [1, 2, 3,],
355}"#;
356        let expected = r#"{
357  "outer": {
358    "inner": "value"
359  },
360  "array": [1, 2, 3]
361}"#;
362        assert_eq!(strip_trailing_commas(input), expected);
363    }
364
365    #[test]
366    fn test_strip_trailing_commas_preserves_valid_commas() {
367        let input = r#"{
368  "key1": "value1",
369  "key2": "value2"
370}"#;
371        // Should not change anything
372        assert_eq!(strip_trailing_commas(input), input);
373    }
374
375    #[test]
376    fn test_strip_trailing_commas_vscode_settings() {
377        let input = r#"{
378  "rust-analyzer.linkedProjects": [
379    "./Cargo.toml",
380  ],
381  "editor.formatOnSave": true,
382}"#;
383        let cleaned = strip_trailing_commas(input);
384        // Should be valid JSON now
385        let result: Result<Value, _> = serde_json::from_str(&cleaned);
386        assert!(result.is_ok());
387
388        let json = result.unwrap();
389        assert_eq!(json["editor.formatOnSave"], true);
390        let projects = json["rust-analyzer.linkedProjects"].as_array().unwrap();
391        assert_eq!(projects.len(), 1);
392    }
393
394    #[test]
395    fn test_merge_linked_projects_when_cargo_toml_exists() {
396        // When ./Cargo.toml already exists, only add ./trident-tests/Cargo.toml
397        let mut existing = json!({
398            "rust-analyzer.linkedProjects": ["./Cargo.toml"],
399            "editor.formatOnSave": true
400        });
401
402        let new = json!({
403            "rust-analyzer.linkedProjects": [
404                "./Cargo.toml",
405                "./trident-tests/Cargo.toml"
406            ]
407        });
408
409        merge_json(&mut existing, &new);
410
411        let projects = existing["rust-analyzer.linkedProjects"].as_array().unwrap();
412        assert_eq!(projects.len(), 2);
413        assert_eq!(projects[0], "./Cargo.toml");
414        assert_eq!(projects[1], "./trident-tests/Cargo.toml");
415        assert_eq!(existing["editor.formatOnSave"], true);
416    }
417
418    #[test]
419    fn test_merge_linked_projects_when_cargo_toml_missing() {
420        // When ./Cargo.toml doesn't exist, add both paths
421        let mut existing = json!({
422            "rust-analyzer.linkedProjects": [],
423            "editor.formatOnSave": true
424        });
425
426        let new = json!({
427            "rust-analyzer.linkedProjects": [
428                "./Cargo.toml",
429                "./trident-tests/Cargo.toml"
430            ]
431        });
432
433        merge_json(&mut existing, &new);
434
435        let projects = existing["rust-analyzer.linkedProjects"].as_array().unwrap();
436        assert_eq!(projects.len(), 2);
437        assert!(projects.contains(&json!("./Cargo.toml")));
438        assert!(projects.contains(&json!("./trident-tests/Cargo.toml")));
439        assert_eq!(existing["editor.formatOnSave"], true);
440    }
441
442    #[test]
443    fn test_merge_linked_projects_when_both_exist() {
444        // When both paths already exist, don't add duplicates
445        let mut existing = json!({
446            "rust-analyzer.linkedProjects": [
447                "./Cargo.toml",
448                "./trident-tests/Cargo.toml"
449            ]
450        });
451
452        let new = json!({
453            "rust-analyzer.linkedProjects": [
454                "./Cargo.toml",
455                "./trident-tests/Cargo.toml"
456            ]
457        });
458
459        merge_json(&mut existing, &new);
460
461        let projects = existing["rust-analyzer.linkedProjects"].as_array().unwrap();
462        assert_eq!(projects.len(), 2);
463    }
464
465    #[test]
466    fn test_merge_linked_projects_with_other_paths() {
467        // When other paths exist, preserve them and add trident-tests
468        let mut existing = json!({
469            "rust-analyzer.linkedProjects": [
470                "./Cargo.toml",
471                "./other-project/Cargo.toml"
472            ]
473        });
474
475        let new = json!({
476            "rust-analyzer.linkedProjects": [
477                "./Cargo.toml",
478                "./trident-tests/Cargo.toml"
479            ]
480        });
481
482        merge_json(&mut existing, &new);
483
484        let projects = existing["rust-analyzer.linkedProjects"].as_array().unwrap();
485        assert_eq!(projects.len(), 3);
486        assert!(projects.contains(&json!("./Cargo.toml")));
487        assert!(projects.contains(&json!("./other-project/Cargo.toml")));
488        assert!(projects.contains(&json!("./trident-tests/Cargo.toml")));
489    }
490
491    #[test]
492    fn test_merge_creates_linked_projects_when_missing() {
493        // When rust-analyzer.linkedProjects doesn't exist, create it with both paths
494        let mut existing = json!({
495            "editor.formatOnSave": true
496        });
497
498        let new = json!({
499            "rust-analyzer.linkedProjects": [
500                "./Cargo.toml",
501                "./trident-tests/Cargo.toml"
502            ]
503        });
504
505        merge_json(&mut existing, &new);
506
507        assert!(existing.get("rust-analyzer.linkedProjects").is_some());
508        let projects = existing["rust-analyzer.linkedProjects"].as_array().unwrap();
509        assert_eq!(projects.len(), 2);
510        assert!(projects.contains(&json!("./Cargo.toml")));
511        assert!(projects.contains(&json!("./trident-tests/Cargo.toml")));
512        assert_eq!(existing["editor.formatOnSave"], true);
513    }
514
515    #[tokio::test]
516    async fn test_create_or_update_json_file_empty_file() {
517        use tempfile::TempDir;
518
519        let temp_dir = TempDir::new().unwrap();
520        let root = temp_dir.path().to_path_buf();
521        let vscode_dir = root.join(".vscode");
522        std::fs::create_dir_all(&vscode_dir).unwrap();
523
524        let settings_path = vscode_dir.join("settings.json");
525
526        // Create an empty file
527        std::fs::write(&settings_path, "").unwrap();
528
529        let new_content = r#"{
530  "rust-analyzer.linkedProjects": [
531    "./Cargo.toml",
532    "./trident-tests/Cargo.toml"
533  ]
534}"#;
535
536        // Should not create a backup for empty file
537        create_or_update_json_file(&root, &settings_path, new_content)
538            .await
539            .unwrap();
540
541        // Verify no backup was created
542        let backup_path = settings_path.with_extension("json.backup");
543        assert!(!backup_path.exists());
544
545        // Verify the new content was written
546        let content = std::fs::read_to_string(&settings_path).unwrap();
547        let json: Value = serde_json::from_str(&content).unwrap();
548        assert!(json.get("rust-analyzer.linkedProjects").is_some());
549    }
550
551    #[tokio::test]
552    async fn test_create_or_update_json_file_whitespace_only() {
553        use tempfile::TempDir;
554
555        let temp_dir = TempDir::new().unwrap();
556        let root = temp_dir.path().to_path_buf();
557        let vscode_dir = root.join(".vscode");
558        std::fs::create_dir_all(&vscode_dir).unwrap();
559
560        let settings_path = vscode_dir.join("settings.json");
561
562        // Create a file with only whitespace
563        std::fs::write(&settings_path, "   \n\t  \n  ").unwrap();
564
565        let new_content = r#"{
566  "rust-analyzer.linkedProjects": [
567    "./Cargo.toml",
568    "./trident-tests/Cargo.toml"
569  ]
570}"#;
571
572        // Should not create a backup for whitespace-only file
573        create_or_update_json_file(&root, &settings_path, new_content)
574            .await
575            .unwrap();
576
577        // Verify no backup was created
578        let backup_path = settings_path.with_extension("json.backup");
579        assert!(!backup_path.exists());
580
581        // Verify the new content was written
582        let content = std::fs::read_to_string(&settings_path).unwrap();
583        let json: Value = serde_json::from_str(&content).unwrap();
584        assert!(json.get("rust-analyzer.linkedProjects").is_some());
585    }
586}