dampen_cli/commands/add/
view_switching.rs

1//! View switching activation logic for enabling multi-view in main.rs
2//!
3//! When a second UI window is added to a project, this module handles:
4//! 1. Detecting if there are 2+ UI modules in src/ui/
5//! 2. Uncommenting or adding `SwitchToView(CurrentView)` to Message enum
6//! 3. Adding `switch_view_variant = "SwitchToView"` to `#[dampen_app]` macro
7
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12/// Error type for view switching operations
13#[derive(Debug, thiserror::Error)]
14pub enum ViewSwitchError {
15    #[error("Failed to read main.rs: {source}")]
16    MainFileRead {
17        path: PathBuf,
18        #[source]
19        source: io::Error,
20    },
21
22    #[error("Failed to write main.rs: {source}")]
23    MainFileWrite {
24        path: PathBuf,
25        #[source]
26        source: io::Error,
27    },
28
29    #[error("Failed to read directory {path}: {source}")]
30    DirectoryRead {
31        path: PathBuf,
32        #[source]
33        source: io::Error,
34    },
35
36    #[error("Could not find Message enum in main.rs")]
37    MessageEnumNotFound,
38
39    #[error("Could not find #[dampen_app] macro in main.rs")]
40    DampenAppMacroNotFound,
41}
42
43/// Detect if there are 2+ UI modules in src/ui/ directory
44///
45/// # Arguments
46///
47/// * `project_root` - Root directory of the project
48///
49/// # Returns
50///
51/// `true` if there are 2 or more .rs files in src/ui/ (excluding mod.rs)
52pub fn detect_second_window(project_root: &Path) -> Result<bool, ViewSwitchError> {
53    let ui_dir = project_root.join("src/ui");
54
55    if !ui_dir.exists() {
56        return Ok(false);
57    }
58
59    let entries = fs::read_dir(&ui_dir).map_err(|source| ViewSwitchError::DirectoryRead {
60        path: ui_dir.clone(),
61        source,
62    })?;
63
64    let mut rs_file_count = 0;
65    for entry in entries {
66        let entry = entry.map_err(|source| ViewSwitchError::DirectoryRead {
67            path: ui_dir.clone(),
68            source,
69        })?;
70
71        let path = entry.path();
72        if path.is_file() && path.extension().is_some_and(|ext| ext == "rs") {
73            // Ignore mod.rs
74            if path.file_name().is_some_and(|name| name != "mod.rs") {
75                rs_file_count += 1;
76
77                // Early exit if we found 2
78                if rs_file_count >= 2 {
79                    return Ok(true);
80                }
81            }
82        }
83    }
84
85    Ok(false)
86}
87
88/// Activate view switching in main.rs
89///
90/// This function modifies main.rs to enable multi-view support by:
91/// 1. Uncommenting or adding `SwitchToView(CurrentView)` in Message enum
92/// 2. Adding `switch_view_variant = "SwitchToView"` to `#[dampen_app]` macro
93///
94/// # Arguments
95///
96/// * `project_root` - Root directory of the project
97///
98/// # Returns
99///
100/// `Ok(())` if successful, or an error if modifications failed
101pub fn activate_view_switching(project_root: &Path) -> Result<(), ViewSwitchError> {
102    let main_path = project_root.join("src/main.rs");
103
104    let content =
105        fs::read_to_string(&main_path).map_err(|source| ViewSwitchError::MainFileRead {
106            path: main_path.clone(),
107            source,
108        })?;
109
110    // Step 1: Uncomment or add SwitchToView to Message enum
111    let content = uncomment_or_add_switch_message(&content)?;
112
113    // Step 2: Add switch_view_variant to #[dampen_app] macro
114    let content = add_switch_variant_to_macro(&content)?;
115
116    // Write back
117    fs::write(&main_path, content).map_err(|source| ViewSwitchError::MainFileWrite {
118        path: main_path.clone(),
119        source,
120    })?;
121
122    Ok(())
123}
124
125/// Uncomment or add `SwitchToView(CurrentView)` to Message enum
126///
127/// Patterns handled:
128/// 1. Commented line: `// SwitchToView(CurrentView),` → uncommented
129/// 2. Missing entirely → added after enum declaration
130fn uncomment_or_add_switch_message(content: &str) -> Result<String, ViewSwitchError> {
131    // Check if it's already uncommented and present
132    if content.contains("SwitchToView(CurrentView)")
133        && !content.contains("// SwitchToView(CurrentView)")
134    {
135        // Already present and uncommented
136        return Ok(content.to_string());
137    }
138
139    // Try to uncomment if it exists as a comment
140    if content.contains("// SwitchToView(CurrentView)") {
141        // Uncomment the line
142        let result = content.replace(
143            "// SwitchToView(CurrentView),",
144            "SwitchToView(CurrentView),",
145        );
146        return Ok(result);
147    }
148
149    // Not found, need to add it
150    // Find the Message enum and add it as the first variant
151    let enum_pattern = "enum Message {";
152    if let Some(enum_pos) = content.find(enum_pattern) {
153        let insert_pos = enum_pos + enum_pattern.len();
154        let mut result = String::with_capacity(content.len() + 100);
155        result.push_str(&content[..insert_pos]);
156        result.push_str("\n    /// View switching\n    SwitchToView(CurrentView),");
157        result.push_str(&content[insert_pos..]);
158        return Ok(result);
159    }
160
161    Err(ViewSwitchError::MessageEnumNotFound)
162}
163
164/// Add `switch_view_variant = "SwitchToView"` to #[dampen_app] macro
165///
166/// Patterns handled:
167/// 1. Commented line: `// switch_view_variant = "SwitchToView",` → uncommented
168/// 2. Missing entirely → added before the closing `)]`
169fn add_switch_variant_to_macro(content: &str) -> Result<String, ViewSwitchError> {
170    // Check if it's already uncommented and present
171    if content.contains(r#"switch_view_variant = "SwitchToView""#)
172        && !content.contains(r#"// switch_view_variant = "SwitchToView""#)
173    {
174        // Already present and uncommented
175        return Ok(content.to_string());
176    }
177
178    // Try to uncomment if it exists as a comment
179    if content.contains(r#"// switch_view_variant = "SwitchToView""#) {
180        // Uncomment the line (handle with or without comma)
181        let result = content
182            .replace(
183                r#"// switch_view_variant = "SwitchToView","#,
184                r#"switch_view_variant = "SwitchToView","#,
185            )
186            .replace(
187                r#"// switch_view_variant = "SwitchToView""#,
188                r#"switch_view_variant = "SwitchToView","#,
189            );
190        return Ok(result);
191    }
192
193    // Not found, need to add it
194    // Find the #[dampen_app(...)] macro and add it before the closing )]
195    if let Some(macro_start) = content.find("#[dampen_app(")
196        && let Some(macro_end) = content[macro_start..].find(")]")
197    {
198        let absolute_macro_end = macro_start + macro_end;
199
200        // Get the content before )]
201        let before_close = &content[..absolute_macro_end];
202
203        // Find the last line before )] that's not just whitespace
204        let lines: Vec<&str> = before_close.lines().collect();
205        let last_non_empty_idx = lines.iter().rposition(|line| !line.trim().is_empty());
206
207        if let Some(idx) = last_non_empty_idx {
208            // Rebuild with comma added if needed
209            let mut result = String::with_capacity(content.len() + 100);
210
211            // Add all lines up to the last non-empty line
212            for (i, line) in lines.iter().enumerate() {
213                result.push_str(line);
214                result.push('\n');
215
216                if i == idx {
217                    // This is the last non-empty line
218                    // Add comma if it doesn't have one
219                    if !line.trim_end().ends_with(',') {
220                        // Go back and add comma before the newline
221                        result.pop(); // Remove the \n we just added
222                        result.push(',');
223                        result.push('\n');
224                    }
225
226                    // Add the switch_view_variant line
227                    result.push_str(r#"    switch_view_variant = "SwitchToView","#);
228                    result.push('\n');
229                }
230            }
231
232            // Add the rest of the content (from )] onwards)
233            result.push_str(&content[absolute_macro_end..]);
234            return Ok(result);
235        }
236
237        // Fallback: just insert before )]
238        let mut result = String::with_capacity(content.len() + 100);
239        result.push_str(&content[..absolute_macro_end]);
240        result.push_str(r#"    switch_view_variant = "SwitchToView","#);
241        result.push('\n');
242        result.push_str(&content[absolute_macro_end..]);
243        return Ok(result);
244    }
245
246    Err(ViewSwitchError::DampenAppMacroNotFound)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::fs;
253    use tempfile::TempDir;
254
255    #[test]
256    fn test_detect_second_window_no_ui_dir() {
257        let temp = TempDir::new().unwrap();
258        let result = detect_second_window(temp.path());
259        assert!(result.is_ok());
260        assert!(!result.unwrap());
261    }
262
263    #[test]
264    fn test_detect_second_window_only_one_file() {
265        let temp = TempDir::new().unwrap();
266        let ui_dir = temp.path().join("src/ui");
267        fs::create_dir_all(&ui_dir).unwrap();
268        fs::write(ui_dir.join("window.rs"), "").unwrap();
269        fs::write(ui_dir.join("mod.rs"), "").unwrap();
270
271        let result = detect_second_window(temp.path());
272        assert!(result.is_ok());
273        assert!(!result.unwrap());
274    }
275
276    #[test]
277    fn test_detect_second_window_two_files() {
278        let temp = TempDir::new().unwrap();
279        let ui_dir = temp.path().join("src/ui");
280        fs::create_dir_all(&ui_dir).unwrap();
281        fs::write(ui_dir.join("window.rs"), "").unwrap();
282        fs::write(ui_dir.join("settings.rs"), "").unwrap();
283        fs::write(ui_dir.join("mod.rs"), "").unwrap();
284
285        let result = detect_second_window(temp.path());
286        assert!(result.is_ok());
287        assert!(result.unwrap());
288    }
289
290    #[test]
291    fn test_uncomment_switch_message() {
292        let input = r#"
293enum Message {
294    // SwitchToView(CurrentView),
295    Handler(HandlerMessage),
296}
297"#;
298
299        let result = uncomment_or_add_switch_message(input).unwrap();
300        assert!(result.contains("SwitchToView(CurrentView),"));
301        assert!(!result.contains("// SwitchToView(CurrentView),"));
302    }
303
304    #[test]
305    fn test_add_switch_message_when_missing() {
306        let input = r#"
307enum Message {
308    Handler(HandlerMessage),
309}
310"#;
311
312        let result = uncomment_or_add_switch_message(input).unwrap();
313        assert!(result.contains("SwitchToView(CurrentView),"));
314        assert!(result.contains("/// View switching"));
315    }
316
317    #[test]
318    fn test_uncomment_switch_variant_in_macro() {
319        let input = r#"
320#[dampen_app(
321    ui_dir = "src/ui",
322    message_type = "Message",
323    // switch_view_variant = "SwitchToView",
324)]
325"#;
326
327        let result = add_switch_variant_to_macro(input).unwrap();
328        assert!(result.contains(r#"switch_view_variant = "SwitchToView","#));
329        assert!(!result.contains(r#"// switch_view_variant"#));
330    }
331
332    #[test]
333    fn test_add_switch_variant_when_missing() {
334        let input = r#"
335#[dampen_app(
336    ui_dir = "src/ui",
337    message_type = "Message"
338)]
339"#;
340
341        let result = add_switch_variant_to_macro(input).unwrap();
342        assert!(result.contains(r#"switch_view_variant = "SwitchToView","#));
343        // Should add comma to previous line
344        assert!(result.contains(r#"message_type = "Message","#));
345    }
346
347    #[test]
348    fn test_add_switch_variant_when_missing_with_comma() {
349        let input = r#"
350#[dampen_app(
351    ui_dir = "src/ui",
352    message_type = "Message",
353)]
354"#;
355
356        let result = add_switch_variant_to_macro(input).unwrap();
357        assert!(result.contains(r#"switch_view_variant = "SwitchToView","#));
358        // Should keep existing comma
359        assert!(result.contains(r#"message_type = "Message","#));
360    }
361
362    #[test]
363    fn test_already_activated_no_changes() {
364        let input = r#"
365enum Message {
366    SwitchToView(CurrentView),
367    Handler(HandlerMessage),
368}
369
370#[dampen_app(
371    ui_dir = "src/ui",
372    switch_view_variant = "SwitchToView",
373)]
374"#;
375
376        let result1 = uncomment_or_add_switch_message(input).unwrap();
377        let result2 = add_switch_variant_to_macro(&result1).unwrap();
378
379        // Should be unchanged
380        assert_eq!(result1, input);
381        assert_eq!(result2, result1);
382    }
383}