Skip to main content

debtmap/analysis/
io_detection.rs

1//! I/O and Side Effect Detection Module
2//!
3//! This module provides static analysis to detect I/O operations and side effects
4//! in code, enabling responsibility classification based on actual behavior rather
5//! than naming conventions alone.
6//!
7//! # Supported Languages
8//!
9//! - Rust: std::fs, std::io, std::net, println!, env::var
10//! - Python: open(), pathlib, requests, print(), os.environ
11//! - JavaScript/TypeScript: fs, fetch, console, process.env
12//!
13//! # Example
14//!
15//! ```ignore
16//! use debtmap::analysis::io_detection::{IoDetector, Language};
17//!
18//! let detector = IoDetector::new();
19//! let profile = detector.analyze_function(&function_ast, Language::Rust);
20//!
21//! if profile.has_file_io() {
22//!     println!("Function performs file I/O operations");
23//! }
24//! ```
25
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28
29/// Unique identifier for a function
30pub type FunctionId = String;
31
32/// Language for I/O pattern detection
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34pub enum Language {
35    Rust,
36    Python,
37    JavaScript,
38    TypeScript,
39}
40
41/// Main I/O detector
42pub struct IoDetector {
43    /// Language-specific I/O patterns
44    patterns: HashMap<Language, IoPatternSet>,
45}
46
47impl IoDetector {
48    /// Create a new I/O detector with default patterns
49    pub fn new() -> Self {
50        let mut patterns = HashMap::new();
51        patterns.insert(Language::Rust, IoPatternSet::for_rust());
52        patterns.insert(Language::Python, IoPatternSet::for_python());
53        patterns.insert(Language::JavaScript, IoPatternSet::for_javascript());
54        patterns.insert(Language::TypeScript, IoPatternSet::for_typescript());
55
56        Self { patterns }
57    }
58
59    /// Detect I/O operations in a function
60    pub fn detect_io(&self, code: &str, language: Language) -> IoProfile {
61        let pattern_set = self
62            .patterns
63            .get(&language)
64            .expect("Language not supported");
65
66        let mut profile = IoProfile::new();
67
68        // Detect file operations
69        for pattern in &pattern_set.file_ops {
70            if code.contains(pattern) {
71                profile
72                    .file_operations
73                    .push(IoOperation::FileRead { path_expr: None });
74            }
75        }
76
77        // Detect network operations
78        for pattern in &pattern_set.network_ops {
79            if code.contains(pattern) {
80                profile
81                    .network_operations
82                    .push(IoOperation::NetworkRequest { endpoint: None });
83            }
84        }
85
86        // Detect console operations
87        for pattern in &pattern_set.console_ops {
88            if code.contains(pattern) {
89                profile.console_operations.push(IoOperation::ConsoleOutput {
90                    stream: OutputStream::Stdout,
91                });
92            }
93        }
94
95        // Detect database operations
96        for pattern in &pattern_set.db_ops {
97            if code.contains(pattern) {
98                profile
99                    .database_operations
100                    .push(IoOperation::DatabaseQuery {
101                        query_type: QueryType::Select,
102                    });
103            }
104        }
105
106        // Detect environment variable access
107        for pattern in &pattern_set.env_ops {
108            if code.contains(pattern) {
109                profile
110                    .environment_operations
111                    .push(IoOperation::EnvironmentAccess { var_name: None });
112            }
113        }
114
115        // Detect field mutations
116        for pattern in &pattern_set.field_mutations {
117            if code.contains(pattern) {
118                profile.side_effects.push(SideEffect::FieldMutation {
119                    target: "unknown".to_string(),
120                    field: "unknown".to_string(),
121                });
122            }
123        }
124
125        // Detect global mutations
126        for pattern in &pattern_set.global_mutations {
127            if code.contains(pattern) {
128                profile.side_effects.push(SideEffect::GlobalMutation {
129                    name: "unknown".to_string(),
130                });
131            }
132        }
133
134        // Detect collection mutations
135        for pattern in &pattern_set.collection_mutations {
136            if code.contains(pattern) {
137                let op = Self::classify_collection_op(pattern);
138                profile
139                    .side_effects
140                    .push(SideEffect::CollectionMutation { operation: op });
141            }
142        }
143
144        // Update purity status
145        profile.is_pure = profile.file_operations.is_empty()
146            && profile.network_operations.is_empty()
147            && profile.console_operations.is_empty()
148            && profile.database_operations.is_empty()
149            && profile.environment_operations.is_empty()
150            && profile.side_effects.is_empty();
151
152        profile
153    }
154
155    /// Classify collection operation from pattern
156    fn classify_collection_op(pattern: &str) -> CollectionOp {
157        if pattern.contains("push") {
158            CollectionOp::Push
159        } else if pattern.contains("pop") {
160            CollectionOp::Pop
161        } else if pattern.contains("insert") {
162            CollectionOp::Insert
163        } else if pattern.contains("remove") {
164            CollectionOp::Remove
165        } else if pattern.contains("clear") {
166            CollectionOp::Clear
167        } else {
168            CollectionOp::Push // Default fallback
169        }
170    }
171}
172
173impl Default for IoDetector {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179/// Set of I/O patterns for a language
180#[derive(Debug, Clone)]
181pub struct IoPatternSet {
182    pub file_ops: Vec<String>,
183    pub network_ops: Vec<String>,
184    pub console_ops: Vec<String>,
185    pub db_ops: Vec<String>,
186    pub env_ops: Vec<String>,
187    pub field_mutations: Vec<String>,
188    pub global_mutations: Vec<String>,
189    pub collection_mutations: Vec<String>,
190}
191
192impl IoPatternSet {
193    /// Patterns for Rust
194    pub fn for_rust() -> Self {
195        Self {
196            file_ops: vec![
197                "std::fs::read".to_string(),
198                "std::fs::write".to_string(),
199                "std::fs::File::open".to_string(),
200                "std::fs::File::create".to_string(),
201                "std::fs::OpenOptions".to_string(),
202                "std::fs::remove".to_string(),
203                "std::fs::copy".to_string(),
204                "std::fs::rename".to_string(),
205                "fs::read".to_string(),
206                "fs::write".to_string(),
207                "File::open".to_string(),
208                "File::create".to_string(),
209                "read_to_string".to_string(),
210                "write_all".to_string(),
211            ],
212            network_ops: vec![
213                "reqwest::".to_string(),
214                "hyper::".to_string(),
215                "std::net::TcpStream".to_string(),
216                "std::net::TcpListener".to_string(),
217                "std::net::UdpSocket".to_string(),
218                "TcpStream::connect".to_string(),
219                "TcpListener::bind".to_string(),
220            ],
221            console_ops: vec![
222                "println!".to_string(),
223                "print!".to_string(),
224                "eprintln!".to_string(),
225                "eprint!".to_string(),
226                "dbg!".to_string(),
227            ],
228            db_ops: vec![
229                "diesel::".to_string(),
230                "sqlx::".to_string(),
231                "rusqlite::".to_string(),
232                "execute".to_string(),
233                "query".to_string(),
234            ],
235            env_ops: vec![
236                "std::env::var".to_string(),
237                "std::env::set_var".to_string(),
238                "env::var".to_string(),
239                "env::set_var".to_string(),
240            ],
241            field_mutations: vec![
242                "self.".to_string(),
243                ".set_".to_string(),
244                ".update_".to_string(),
245            ],
246            global_mutations: vec![
247                "static mut".to_string(),
248                "GLOBAL_".to_string(),
249                "unsafe {".to_string(),
250            ],
251            collection_mutations: vec![
252                ".push(".to_string(),
253                ".pop(".to_string(),
254                ".insert(".to_string(),
255                ".remove(".to_string(),
256                ".clear(".to_string(),
257                ".append(".to_string(),
258                ".extend(".to_string(),
259            ],
260        }
261    }
262
263    /// Patterns for Python
264    pub fn for_python() -> Self {
265        Self {
266            file_ops: vec![
267                "open(".to_string(),
268                "pathlib.Path".to_string(),
269                ".read_text(".to_string(),
270                ".write_text(".to_string(),
271                ".read_bytes(".to_string(),
272                ".write_bytes(".to_string(),
273                "os.path.".to_string(),
274                "shutil.".to_string(),
275            ],
276            network_ops: vec![
277                "requests.".to_string(),
278                "urllib.".to_string(),
279                "http.client.".to_string(),
280                "socket.".to_string(),
281                "httpx.".to_string(),
282            ],
283            console_ops: vec![
284                "print(".to_string(),
285                "input(".to_string(),
286                "sys.stdout.".to_string(),
287                "sys.stderr.".to_string(),
288            ],
289            db_ops: vec![
290                "sqlite3.".to_string(),
291                "psycopg2.".to_string(),
292                "pymongo.".to_string(),
293                ".execute(".to_string(),
294                ".fetchall(".to_string(),
295                ".fetchone(".to_string(),
296            ],
297            env_ops: vec![
298                "os.environ".to_string(),
299                "os.getenv(".to_string(),
300                "os.putenv(".to_string(),
301            ],
302            field_mutations: vec![
303                "self.".to_string(),
304                ".set_".to_string(),
305                ".update_".to_string(),
306            ],
307            global_mutations: vec!["global ".to_string(), "GLOBAL_".to_string()],
308            collection_mutations: vec![
309                ".append(".to_string(),
310                ".pop(".to_string(),
311                ".insert(".to_string(),
312                ".remove(".to_string(),
313                ".clear(".to_string(),
314                ".extend(".to_string(),
315            ],
316        }
317    }
318
319    /// Patterns for JavaScript
320    pub fn for_javascript() -> Self {
321        Self {
322            file_ops: vec![
323                "fs.readFile".to_string(),
324                "fs.writeFile".to_string(),
325                "fs.readFileSync".to_string(),
326                "fs.writeFileSync".to_string(),
327                "fs.promises.".to_string(),
328                "require('fs')".to_string(),
329            ],
330            network_ops: vec![
331                "fetch(".to_string(),
332                "axios.".to_string(),
333                "XMLHttpRequest".to_string(),
334                "http.request".to_string(),
335                "https.request".to_string(),
336            ],
337            console_ops: vec![
338                "console.log".to_string(),
339                "console.error".to_string(),
340                "console.warn".to_string(),
341                "console.debug".to_string(),
342            ],
343            db_ops: vec![
344                "mongoose.".to_string(),
345                "sequelize.".to_string(),
346                ".query(".to_string(),
347                ".find(".to_string(),
348                ".findOne(".to_string(),
349            ],
350            env_ops: vec!["process.env".to_string(), "process.env.".to_string()],
351            field_mutations: vec![
352                "this.".to_string(),
353                ".set(".to_string(),
354                ".update(".to_string(),
355            ],
356            global_mutations: vec![
357                "window.".to_string(),
358                "global.".to_string(),
359                "GLOBAL_".to_string(),
360            ],
361            collection_mutations: vec![
362                ".push(".to_string(),
363                ".pop(".to_string(),
364                ".splice(".to_string(),
365                ".shift(".to_string(),
366                ".unshift(".to_string(),
367            ],
368        }
369    }
370
371    /// Patterns for TypeScript (same as JavaScript)
372    pub fn for_typescript() -> Self {
373        Self::for_javascript()
374    }
375}
376
377/// I/O operation types
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379pub enum IoOperation {
380    FileRead { path_expr: Option<String> },
381    FileWrite { path_expr: Option<String> },
382    NetworkRequest { endpoint: Option<String> },
383    ConsoleOutput { stream: OutputStream },
384    DatabaseQuery { query_type: QueryType },
385    EnvironmentAccess { var_name: Option<String> },
386}
387
388/// Output stream type
389#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
390pub enum OutputStream {
391    Stdout,
392    Stderr,
393}
394
395/// Database query type
396#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
397pub enum QueryType {
398    Select,
399    Insert,
400    Update,
401    Delete,
402}
403
404/// Side effect types
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
406pub enum SideEffect {
407    /// Mutation of field in self or other object
408    FieldMutation { target: String, field: String },
409    /// Mutation of global/static variable
410    GlobalMutation { name: String },
411    /// Array/collection mutation
412    CollectionMutation { operation: CollectionOp },
413    /// External state change
414    ExternalState { description: String },
415}
416
417/// Collection operation type
418#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
419pub enum CollectionOp {
420    Push,
421    Pop,
422    Insert,
423    Remove,
424    Clear,
425}
426
427/// I/O profile for a function
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct IoProfile {
430    pub file_operations: Vec<IoOperation>,
431    pub network_operations: Vec<IoOperation>,
432    pub console_operations: Vec<IoOperation>,
433    pub database_operations: Vec<IoOperation>,
434    pub environment_operations: Vec<IoOperation>,
435    pub side_effects: Vec<SideEffect>,
436    pub is_pure: bool,
437}
438
439impl IoProfile {
440    /// Create a new empty I/O profile
441    pub fn new() -> Self {
442        Self {
443            file_operations: Vec::new(),
444            network_operations: Vec::new(),
445            console_operations: Vec::new(),
446            database_operations: Vec::new(),
447            environment_operations: Vec::new(),
448            side_effects: Vec::new(),
449            is_pure: true,
450        }
451    }
452
453    /// Check if function has file I/O
454    pub fn has_file_io(&self) -> bool {
455        !self.file_operations.is_empty()
456    }
457
458    /// Check if function has network I/O
459    pub fn has_network_io(&self) -> bool {
460        !self.network_operations.is_empty()
461    }
462
463    /// Check if function has console I/O
464    pub fn has_console_io(&self) -> bool {
465        !self.console_operations.is_empty()
466    }
467
468    /// Check if function has database I/O
469    pub fn has_database_io(&self) -> bool {
470        !self.database_operations.is_empty()
471    }
472
473    /// Classify responsibility based on I/O pattern
474    pub fn primary_responsibility(&self) -> Responsibility {
475        match (
476            self.file_operations.is_empty(),
477            self.network_operations.is_empty(),
478            self.console_operations.is_empty(),
479            self.database_operations.is_empty(),
480            self.is_pure,
481        ) {
482            (false, _, _, _, _) => Responsibility::FileIO,
483            (_, false, _, _, _) => Responsibility::NetworkIO,
484            (_, _, false, _, _) => Responsibility::ConsoleIO,
485            (_, _, _, false, _) => Responsibility::DatabaseIO,
486            (true, true, true, true, true) => Responsibility::PureComputation,
487            _ => Responsibility::MixedIO,
488        }
489    }
490
491    /// I/O intensity score (higher = more I/O heavy)
492    pub fn intensity(&self) -> f64 {
493        (self.file_operations.len()
494            + self.network_operations.len()
495            + self.console_operations.len()
496            + self.database_operations.len()
497            + self.environment_operations.len()) as f64
498    }
499
500    /// Merge another profile into this one
501    pub fn merge(&mut self, other: &IoProfile) {
502        self.file_operations.extend(other.file_operations.clone());
503        self.network_operations
504            .extend(other.network_operations.clone());
505        self.console_operations
506            .extend(other.console_operations.clone());
507        self.database_operations
508            .extend(other.database_operations.clone());
509        self.environment_operations
510            .extend(other.environment_operations.clone());
511        self.side_effects.extend(other.side_effects.clone());
512        self.is_pure = self.is_pure && other.is_pure;
513    }
514}
515
516impl Default for IoProfile {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522/// Responsibility classification based on I/O behavior
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
524pub enum Responsibility {
525    PureComputation,
526    FileIO,
527    NetworkIO,
528    ConsoleIO,
529    DatabaseIO,
530    MixedIO,
531    SideEffects,
532}
533
534impl Responsibility {
535    /// Convert to a human-readable string
536    pub fn as_str(&self) -> &'static str {
537        match self {
538            Responsibility::PureComputation => "Pure Computation",
539            Responsibility::FileIO => "File I/O",
540            Responsibility::NetworkIO => "Network I/O",
541            Responsibility::ConsoleIO => "Console I/O",
542            Responsibility::DatabaseIO => "Database I/O",
543            Responsibility::MixedIO => "Mixed I/O",
544            Responsibility::SideEffects => "Side Effects",
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn test_rust_file_io_detection() {
555        let code = r#"
556        fn read_config() -> String {
557            std::fs::read_to_string("config.toml").unwrap()
558        }
559        "#;
560
561        let detector = IoDetector::new();
562        let profile = detector.detect_io(code, Language::Rust);
563
564        // Multiple patterns may match (std::fs::read, read_to_string, fs::read)
565        assert!(!profile.file_operations.is_empty());
566        assert_eq!(profile.primary_responsibility(), Responsibility::FileIO);
567        assert!(!profile.is_pure);
568    }
569
570    #[test]
571    fn test_rust_network_io_detection() {
572        let code = r#"
573        fn fetch_data() {
574            let client = reqwest::blocking::Client::new();
575            let response = client.get("https://api.example.com").send();
576        }
577        "#;
578
579        let detector = IoDetector::new();
580        let profile = detector.detect_io(code, Language::Rust);
581
582        assert_eq!(profile.network_operations.len(), 1);
583        assert_eq!(profile.primary_responsibility(), Responsibility::NetworkIO);
584    }
585
586    #[test]
587    fn test_rust_console_io_detection() {
588        let code = r#"
589        fn log_message(msg: &str) {
590            println!("Message: {}", msg);
591        }
592        "#;
593
594        let detector = IoDetector::new();
595        let profile = detector.detect_io(code, Language::Rust);
596
597        assert_eq!(profile.console_operations.len(), 1);
598        assert_eq!(profile.primary_responsibility(), Responsibility::ConsoleIO);
599    }
600
601    #[test]
602    fn test_pure_function_detection() {
603        let code = r#"
604        fn calculate_sum(a: i32, b: i32) -> i32 {
605            a + b
606        }
607        "#;
608
609        let detector = IoDetector::new();
610        let profile = detector.detect_io(code, Language::Rust);
611
612        assert!(profile.is_pure);
613        assert_eq!(
614            profile.primary_responsibility(),
615            Responsibility::PureComputation
616        );
617        assert_eq!(profile.intensity(), 0.0);
618    }
619
620    #[test]
621    fn test_python_file_io_detection() {
622        let code = r#"
623        def read_config():
624            with open('config.json') as f:
625                return f.read()
626        "#;
627
628        let detector = IoDetector::new();
629        let profile = detector.detect_io(code, Language::Python);
630
631        assert_eq!(profile.file_operations.len(), 1);
632        assert_eq!(profile.primary_responsibility(), Responsibility::FileIO);
633    }
634
635    #[test]
636    fn test_python_network_io_detection() {
637        let code = r#"
638        def fetch_data():
639            response = requests.get('https://api.example.com/data')
640            return response.json()
641        "#;
642
643        let detector = IoDetector::new();
644        let profile = detector.detect_io(code, Language::Python);
645
646        assert_eq!(profile.network_operations.len(), 1);
647        assert_eq!(profile.primary_responsibility(), Responsibility::NetworkIO);
648    }
649
650    #[test]
651    fn test_javascript_file_io_detection() {
652        let code = r#"
653        function readConfig() {
654            return fs.readFileSync('config.json', 'utf8');
655        }
656        "#;
657
658        let detector = IoDetector::new();
659        let profile = detector.detect_io(code, Language::JavaScript);
660
661        // Multiple patterns may match (fs.readFile, fs.readFileSync)
662        assert!(!profile.file_operations.is_empty());
663        assert_eq!(profile.primary_responsibility(), Responsibility::FileIO);
664    }
665
666    #[test]
667    fn test_javascript_network_io_detection() {
668        let code = r#"
669        async function fetchData() {
670            const response = await fetch('https://api.example.com');
671            return await response.json();
672        }
673        "#;
674
675        let detector = IoDetector::new();
676        let profile = detector.detect_io(code, Language::JavaScript);
677
678        assert_eq!(profile.network_operations.len(), 1);
679        assert_eq!(profile.primary_responsibility(), Responsibility::NetworkIO);
680    }
681
682    #[test]
683    fn test_mixed_io_detection() {
684        let code = r#"
685        fn process_and_log() {
686            let data = std::fs::read_to_string("input.txt").unwrap();
687            println!("Processing: {}", data);
688        }
689        "#;
690
691        let detector = IoDetector::new();
692        let profile = detector.detect_io(code, Language::Rust);
693
694        assert!(!profile.file_operations.is_empty());
695        assert!(!profile.console_operations.is_empty());
696        assert!(profile.intensity() > 1.0);
697    }
698
699    #[test]
700    fn test_io_profile_merge() {
701        let mut profile1 = IoProfile::new();
702        profile1
703            .file_operations
704            .push(IoOperation::FileRead { path_expr: None });
705
706        let mut profile2 = IoProfile::new();
707        profile2
708            .network_operations
709            .push(IoOperation::NetworkRequest { endpoint: None });
710
711        profile1.merge(&profile2);
712
713        assert_eq!(profile1.file_operations.len(), 1);
714        assert_eq!(profile1.network_operations.len(), 1);
715    }
716
717    #[test]
718    fn test_responsibility_as_str() {
719        assert_eq!(Responsibility::PureComputation.as_str(), "Pure Computation");
720        assert_eq!(Responsibility::FileIO.as_str(), "File I/O");
721        assert_eq!(Responsibility::NetworkIO.as_str(), "Network I/O");
722        assert_eq!(Responsibility::ConsoleIO.as_str(), "Console I/O");
723        assert_eq!(Responsibility::DatabaseIO.as_str(), "Database I/O");
724        assert_eq!(Responsibility::MixedIO.as_str(), "Mixed I/O");
725        assert_eq!(Responsibility::SideEffects.as_str(), "Side Effects");
726    }
727
728    #[test]
729    fn test_rust_field_mutation_detection() {
730        let code = r#"
731        fn update_counter(&mut self) {
732            self.count += 1;
733        }
734        "#;
735
736        let detector = IoDetector::new();
737        let profile = detector.detect_io(code, Language::Rust);
738
739        assert!(!profile.side_effects.is_empty());
740        assert!(!profile.is_pure);
741        assert!(matches!(
742            profile.side_effects[0],
743            SideEffect::FieldMutation { .. }
744        ));
745    }
746
747    #[test]
748    fn test_rust_collection_mutation_detection() {
749        let code = r#"
750        fn add_item(&mut self, item: i32) {
751            self.items.push(item);
752        }
753        "#;
754
755        let detector = IoDetector::new();
756        let profile = detector.detect_io(code, Language::Rust);
757
758        assert!(!profile.side_effects.is_empty());
759        assert!(!profile.is_pure);
760        // Should contain a collection mutation (may not be first due to field mutation)
761        assert!(profile.side_effects.iter().any(|e| matches!(
762            e,
763            SideEffect::CollectionMutation {
764                operation: CollectionOp::Push
765            }
766        )));
767    }
768
769    #[test]
770    fn test_rust_global_mutation_detection() {
771        let code = r#"
772        static mut GLOBAL_COUNTER: i32 = 0;
773        fn increment_global() {
774            unsafe {
775                GLOBAL_COUNTER += 1;
776            }
777        }
778        "#;
779
780        let detector = IoDetector::new();
781        let profile = detector.detect_io(code, Language::Rust);
782
783        assert!(!profile.side_effects.is_empty());
784        assert!(!profile.is_pure);
785        // Should detect both "static mut" and "unsafe {" patterns
786        assert!(!profile.side_effects.is_empty());
787    }
788
789    #[test]
790    fn test_python_field_mutation_detection() {
791        let code = r#"
792        def update_counter(self):
793            self.count += 1
794        "#;
795
796        let detector = IoDetector::new();
797        let profile = detector.detect_io(code, Language::Python);
798
799        assert!(!profile.side_effects.is_empty());
800        assert!(!profile.is_pure);
801    }
802
803    #[test]
804    fn test_python_collection_mutation_detection() {
805        let code = r#"
806        def add_item(self, item):
807            self.items.append(item)
808        "#;
809
810        let detector = IoDetector::new();
811        let profile = detector.detect_io(code, Language::Python);
812
813        assert!(!profile.side_effects.is_empty());
814        assert!(!profile.is_pure);
815        // Should contain a collection mutation (may not be first due to field mutation)
816        assert!(profile.side_effects.iter().any(|e| matches!(
817            e,
818            SideEffect::CollectionMutation {
819                operation: CollectionOp::Push
820            }
821        )));
822    }
823
824    #[test]
825    fn test_python_global_mutation_detection() {
826        let code = r#"
827        def increment_global():
828            global counter
829            counter += 1
830        "#;
831
832        let detector = IoDetector::new();
833        let profile = detector.detect_io(code, Language::Python);
834
835        assert!(!profile.side_effects.is_empty());
836        assert!(!profile.is_pure);
837    }
838
839    #[test]
840    fn test_javascript_field_mutation_detection() {
841        let code = r#"
842        function updateCounter() {
843            this.count += 1;
844        }
845        "#;
846
847        let detector = IoDetector::new();
848        let profile = detector.detect_io(code, Language::JavaScript);
849
850        assert!(!profile.side_effects.is_empty());
851        assert!(!profile.is_pure);
852    }
853
854    #[test]
855    fn test_javascript_collection_mutation_detection() {
856        let code = r#"
857        function addItem(item) {
858            this.items.push(item);
859        }
860        "#;
861
862        let detector = IoDetector::new();
863        let profile = detector.detect_io(code, Language::JavaScript);
864
865        assert!(!profile.side_effects.is_empty());
866        assert!(!profile.is_pure);
867        // Should contain a collection mutation (may not be first due to field mutation)
868        assert!(profile.side_effects.iter().any(|e| matches!(
869            e,
870            SideEffect::CollectionMutation {
871                operation: CollectionOp::Push
872            }
873        )));
874    }
875
876    #[test]
877    fn test_javascript_global_mutation_detection() {
878        let code = r#"
879        function updateGlobal() {
880            window.globalCounter += 1;
881        }
882        "#;
883
884        let detector = IoDetector::new();
885        let profile = detector.detect_io(code, Language::JavaScript);
886
887        assert!(!profile.side_effects.is_empty());
888        assert!(!profile.is_pure);
889    }
890
891    #[test]
892    fn test_multiple_collection_operations() {
893        let code = r#"
894        fn manage_items(&mut self) {
895            self.items.push(1);
896            self.items.pop();
897            self.items.insert(0, 2);
898            self.items.remove(1);
899            self.items.clear();
900        }
901        "#;
902
903        let detector = IoDetector::new();
904        let profile = detector.detect_io(code, Language::Rust);
905
906        assert!(profile.side_effects.len() >= 5);
907        assert!(!profile.is_pure);
908    }
909
910    #[test]
911    fn test_pure_function_no_side_effects() {
912        let code = r#"
913        fn calculate_sum(a: i32, b: i32) -> i32 {
914            a + b
915        }
916        "#;
917
918        let detector = IoDetector::new();
919        let profile = detector.detect_io(code, Language::Rust);
920
921        assert!(profile.side_effects.is_empty());
922        assert!(profile.is_pure);
923    }
924
925    #[test]
926    fn test_side_effects_affect_purity() {
927        let code_pure = r#"fn pure(x: i32) -> i32 { x * 2 }"#;
928        let code_impure = r#"fn impure(&mut self) { self.value = 42; }"#;
929
930        let detector = IoDetector::new();
931        let profile_pure = detector.detect_io(code_pure, Language::Rust);
932        let profile_impure = detector.detect_io(code_impure, Language::Rust);
933
934        assert!(profile_pure.is_pure);
935        assert!(!profile_impure.is_pure);
936    }
937}